├── .flake8 ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── django_insights ├── __init__.py ├── apps.py ├── charts.py ├── choices.py ├── database.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── collect_insights.py ├── managers.py ├── metrics.py ├── metrics_types.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── packages │ ├── package.json │ ├── pnpm-lock.yaml │ ├── src │ │ ├── index.js │ │ └── input.css │ └── tailwind.config.js ├── pytest.py ├── registry.py ├── settings.py ├── static │ └── insights │ │ ├── css │ │ ├── insights.css │ │ └── pdf.css │ │ ├── fonts │ │ └── ubuntu.ttf │ │ └── js │ │ └── insights.js ├── templates │ └── insights │ │ ├── app.html │ │ ├── base.html │ │ ├── dashboard.html │ │ └── pdf.html ├── templatetags │ ├── __init__.py │ ├── insight_chart.py │ └── nice_format.py ├── urls.py ├── utils.py └── views.py ├── docs ├── alternatives.md ├── assets │ └── images │ │ ├── banner.png │ │ ├── screen_1.png │ │ └── screen_2.png ├── index.md ├── installation.md ├── license.md ├── stylesheets │ └── extra.css └── types.md ├── manage.py ├── mkdocs.yml ├── project ├── __init__.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── dev.py │ └── test.py ├── testapp │ ├── __init__.py │ ├── apps.py │ ├── insights.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── seed_db.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ └── users │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── insights.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_appuser_created.py │ │ └── __init__.py │ │ └── models.py └── urls.py ├── pyproject.toml ├── requirements ├── compile.py ├── py310-django32.txt ├── py310-django40.txt ├── py310-django41.txt ├── py310-django42.txt ├── py311-django41.txt ├── py311-django42.txt ├── py38-django32.txt ├── py38-django40.txt ├── py38-django41.txt ├── py38-django42.txt ├── py39-django32.txt ├── py39-django40.txt ├── py39-django41.txt ├── py39-django42.txt └── requirements.in ├── tests ├── __init__.py ├── test_api.py └── utils.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203,E501 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | django_insights/static/* linguist-vendored 2 | django_insights/packages/* linguist-vendored 3 | docs/* linguist-documentation 4 | 5 | *.py linguist-detectable=true 6 | *.js linguist-detectable=false 7 | *.html linguist-detectable=false 8 | *.css linguist-detectable=false -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | tests: 15 | name: Python ${{ matrix.python-version }} 16 | runs-on: ubuntu-22.04 17 | 18 | strategy: 19 | matrix: 20 | python-version: 21 | - 3.8 22 | - 3.9 23 | - "3.10" 24 | - "3.11" 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | 29 | - uses: actions/setup-python@v4 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | cache: pip 33 | cache-dependency-path: "requirements/*.txt" 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip setuptools wheel 38 | python -m pip install --upgrade tox 39 | - name: Run tox targets for ${{ matrix.python-version }} 40 | run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 41 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 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 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # Node modules 156 | node_modules/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | # Project specifics 166 | insights.db 167 | testapp.db 168 | db/ 169 | 170 | project/static/ 171 | project/media/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.1.0 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/PyCQA/isort 7 | rev: 5.12.0 8 | hooks: 9 | - id: isort 10 | - repo: https://github.com/PyCQA/flake8 11 | rev: 6.0.0 12 | hooks: 13 | - id: flake8 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2023 terminalkitten and individual contributors. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: rs migrate dump shell seed collect_insights compilereqtxt clean 2 | 3 | rs: 4 | python manage.py runserver 5 | 6 | migrate: 7 | python manage.py migrate 8 | python manage.py migrate insights --database=insights 9 | 10 | shell: 11 | python manage.py shell 12 | 13 | seed: 14 | python manage.py seed_db 15 | 16 | collect_insights: 17 | python manage.py collect_insights 18 | 19 | compilereq: 20 | python requirements/compile.py 21 | 22 | clean: 23 | rm -f db/*.db 24 | rm -rf django_insights/migrations 25 | rm -rf project/testapp/migrations 26 | 27 | python manage.py makemigrations testapp users 28 | python manage.py makemigrations insights 29 | python manage.py migrate 30 | python manage.py migrate insights --database=insights -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/terminalkitten/django-insights/actions/workflows/main.yml/badge.svg)](https://github.com/terminalkitten/django-insights/actions/workflows/main.yml) 2 | 3 | !["Django Insights"](https://raw.githubusercontent.com/terminalkitten/django-insights/main/docs/assets/images/banner.png) 4 | 5 | ## Features 6 | 7 | Create insights for your app, store them in a SQLite database for further processing, these insights are written right next to your application logic. 8 | 9 | ### Note: 10 | 11 | Still working on some small things, extending tests and improving the documentation. 12 | 13 | For now focus is on: 14 | 15 | - Django 3.2 (LTS), 4.0,4.1 and 4.2; 16 | - Python ≥ 3.8 17 | 18 | ## Installation 19 | 20 | Installing with: 21 | 22 | ```bash 23 | pip install 'django-insights' 24 | ``` 25 | 26 | ## Usage 27 | 28 | First create a `insights.py` file in your app directory, for example: 29 | 30 | ```bash 31 | project 32 | └── testapp 33 | └── insights.py 34 | ``` 35 | 36 | Each app can have it's own `insights.py` file, these files are auto-discovered by Django Insights, so at any given location it would pick up your metrics. 37 | 38 | In these insights files you write out any metric you would like to track. Each metric starts with a question and some values to store. Below is a example of the `@metrics.counter` function: 39 | 40 | ```python 41 | # project/testapp/insights.py 42 | from django_insights.metrics import metrics 43 | from project.testapp.models import Author 44 | 45 | label = "Bookstore" 46 | 47 | @metrics.counter(question="How many authors are there?") 48 | def count_authors() -> int: 49 | return Author.objects.count() 50 | 51 | ``` 52 | 53 | Insight apps can have a `label`, this is used in the dashboard or can be read from `insights_app` table later on. 54 | 55 | ### Settings 56 | 57 | Add django_insights package, insights database and router to your settings 58 | 59 | ```python 60 | 61 | INSTALLED_APPS = [ 62 | ... 63 | "django_insights", 64 | ] 65 | 66 | 67 | DATABASES = { 68 | ... 69 | "insights": { 70 | "ENGINE": "django.db.backends.sqlite3", 71 | "NAME": os.path.join(BASE_DIR,"db/insights.db") 72 | }, 73 | ... 74 | } 75 | 76 | DATABASE_ROUTERS = ['django_insights.database.Router'] 77 | 78 | ``` 79 | 80 | Note: please make sure you exclude the database in your `.gitignore` file 81 | 82 | Migrate insights database: 83 | 84 | ```bash 85 | workon myapp 86 | python manage.py migrate insights --database=insights 87 | ``` 88 | 89 | Now collect your insights 90 | 91 | ```bash 92 | python manage.py collect_insights 93 | ``` 94 | 95 | You now have a database containing all insights from your application. 96 | 97 | Note: You need to run the `python manage.py collect_insights` command each time you want to update data on your dashboard. In production, you can setup a cron job to call this command at regular intervals depending on your use case. 98 | 99 | You can inspect this database yourself with `sqlite3 db/insights.db` - or - you can use the Django Insights dashboard. 100 | 101 | ### Dashboard 102 | 103 | !["Dashboard - Main Screen"](https://raw.githubusercontent.com/terminalkitten/django-insights/main/docs/assets/images/screen_1.png) 104 | 105 | To enable this dashboard, add the following settings: 106 | 107 | ```python 108 | from django.urls import include, path 109 | 110 | urlpatterns = [ 111 | path( 112 | 'insights/', 113 | include('django_insights.urls', namespace='insights'), 114 | ), 115 | ] 116 | ``` 117 | 118 | !["Dashboard - App"](https://raw.githubusercontent.com/terminalkitten/django-insights/main/docs/assets/images/screen_2.png) 119 | 120 | Now you can visit https://localhost:8000/insights to inspect your Django Insights database. 121 | 122 | ## Metrics 123 | 124 | Django insights contains 5 types of metrics it can collect: 125 | 126 | - `@metrics.counter` 127 | - `@metrics.gauge` 128 | - `@metrics.timeseries` 129 | - `@metrics.scatterplot` 130 | - `@metrics.barchart` 131 | 132 | ### Counter: 133 | 134 | ```python 135 | from django_insights.metrics import metrics 136 | from project.testapp.models import Author 137 | 138 | 139 | @metrics.counter(question="How many authors are there?") 140 | def count_authors() -> int: 141 | return Author.objects.count() 142 | 143 | ``` 144 | 145 | ### Gauge: 146 | 147 | ```python 148 | 149 | from django.db.models import Avg, Count 150 | 151 | from django_insights.metrics import metrics 152 | from project.testapp.models import Author 153 | 154 | 155 | @metrics.gauge(question="Average book(s) per author?") 156 | def avg_books_per_author() -> int: 157 | avg_total_books = ( 158 | Author.objects.prefetch_related('books') 159 | .annotate(total_books=Count('books')) 160 | .aggregate(Avg('total_books')) 161 | .get('total_books__avg') 162 | ) 163 | 164 | return avg_total_books 165 | ``` 166 | 167 | ### Timeseries: 168 | 169 | ```python 170 | from datetime import datetime 171 | 172 | from django.db.models import Count 173 | from django.db.models.functions import TruncMonth 174 | 175 | from django_insights.metrics import metrics 176 | from project.testapp.models import Book 177 | 178 | 179 | @metrics.timeseries( 180 | question="Num of books created per month?", 181 | desc="How many books are added each month, since the opening of our store", 182 | xlabel="Month", 183 | xformat='%m', 184 | ylabel="Num of books", 185 | ) 186 | def num_of_books_per_month() -> list[tuple[datetime, int]]: 187 | return ( 188 | Book.objects.all() 189 | .annotate(month=TruncMonth('created')) 190 | .values('month') 191 | .filter(month__isnull=False) 192 | .annotate(total=Count('pk')) 193 | .values_list('month', 'total') 194 | .order_by('month') 195 | ) 196 | ``` 197 | 198 | ### Scatterplot: 199 | 200 | ```python 201 | from datetime import datetime 202 | 203 | from django.db.models import Count, Value 204 | 205 | from django_insights.metrics import metrics 206 | from project.testapp.models import Author 207 | 208 | 209 | @metrics.scatterplot( 210 | question="Num of books by age of author?", 211 | xlabel="Age", 212 | ylabel="Num of books", 213 | ) 214 | def author_age_vs_num_of_books() -> list[tuple[float, float, Any]]: 215 | return ( 216 | Author.objects.values('age') 217 | .annotate(num_of_books=Count('books'), category=Value("author")) 218 | .values_list('num_of_books', 'age', 'category') 219 | ) 220 | ``` 221 | 222 | ### Barchart: 223 | 224 | ```python 225 | from datetime import datetime 226 | 227 | from django.db.models import Case, Count, Value, When 228 | 229 | from django_insights.metrics import metrics 230 | from project.testapp.models import Author 231 | 232 | 233 | @metrics.barchart( 234 | question="Num of books by gender of author?", 235 | xlabel="Gender", 236 | ylabel="Num of books", 237 | ) 238 | def author_gender_vs_num_of_books() -> list[tuple[float, float, str]]: 239 | return ( 240 | Author.objects.values('gender') 241 | .annotate( 242 | num_of_books=Count('books'), 243 | gender_category=Case( 244 | When(gender=1, then=Value('Male')), 245 | When(gender=2, then=Value('Female')), 246 | ), 247 | ) 248 | .values_list('num_of_books', 'gender', 'gender_category') 249 | ) 250 | 251 | ``` 252 | 253 | ## Settings 254 | 255 | ```python 256 | # Custom app name 257 | INSIGHTS_APP_NAME = "Bezamon" 258 | 259 | # Quality of chart images 260 | INSIGHTS_CHART_DPI = 180 261 | 262 | # Default theme for dashboard 263 | INSIGHTS_THEME = "dark" 264 | 265 | # Change primary color of dashboard 266 | INSIGHTS_CHART_LIGHT_PRIMARY_COLOR = "#2563EB" 267 | INSIGHTS_CHART_DARK_PRIMARY_COLOR = "#BFDBFE" 268 | 269 | ``` 270 | 271 | ## Use-cases 272 | 273 | Insights are gathered from your current application state, Django Insights is not intentend to be used as a realtime, incremementing data-source. You should be able to re-gather these insights from your actual data at any moment in time. 274 | 275 | Yes: 276 | 277 | - How many users, how many users invited a year 278 | - How many reports a day, how many messages send on Wednesday's 279 | 280 | No: 281 | 282 | - How many GET request for url XXX a second 283 | - Realtime profit target percentage 284 | 285 | ## Background 286 | 287 | I'm currently working at a small company that is in the process of renewing some parts of our product. To gain insight into the usage over different periods, we have tried a few solutions. We initially attempted to periodically generate CSV files from queries, as well as send data to a dashboard at regular intervals. 288 | 289 | We ended up with many exports that where spread out over multiple people. Additionally, exporting data directly from the database also posed a security risk, as it required constant movement of possible sensitive information. After several months of working with CSV files, which were often outdated and required conversion by other (paid) tools, we where looking for a better solution. 290 | 291 | I wanted an easy-to-configure file within our various apps that would allow me to create "insights" easily, so Django Insights was born. I decided to switch to a local SQLite database which could be share on request, as a plus these files can be tracked by a security officer. 292 | 293 | ## Documentation 294 | 295 | Write more about where to find documentation 296 | 297 | ## Ideas 298 | 299 | - Connect to other datasources and export to different file-formats ( ArrowFile?, NDJSON ) 300 | 301 | ## Is it any good? 302 | 303 | [Yes.](http://news.ycombinator.com/item?id=3067434) 304 | 305 | ## License 306 | 307 | The MIT License 308 | -------------------------------------------------------------------------------- /django_insights/__init__.py: -------------------------------------------------------------------------------- 1 | """ Django Insights """ 2 | 3 | __version__ = "0.2.12-alpha" 4 | -------------------------------------------------------------------------------- /django_insights/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class InsightAppConfig(AppConfig): 5 | name = 'django_insights' 6 | label = 'insights' 7 | verbose_name = 'Django Insights' 8 | 9 | default_auto_field = 'django.db.models.AutoField' 10 | -------------------------------------------------------------------------------- /django_insights/charts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import io 5 | import os 6 | from dataclasses import dataclass 7 | from enum import Enum 8 | 9 | import matplotlib 10 | import matplotlib.dates as mdates 11 | import matplotlib.font_manager as fm 12 | import matplotlib.pyplot as plt 13 | from asgiref.sync import sync_to_async 14 | 15 | from django_insights.models import Bucket 16 | from django_insights.settings import settings 17 | 18 | matplotlib.use('Agg') 19 | dir_path = os.path.dirname(os.path.realpath(__file__)) 20 | fm.fontManager.addfont(f"{dir_path}/static/insights/fonts/ubuntu.ttf") 21 | 22 | 23 | plt.rcParams['font.family'] = 'sans-serif' 24 | plt.rcParams['font.sans-serif'] = 'Ubuntu' 25 | 26 | 27 | class ChartSize(Enum): 28 | SMALL = 'small' 29 | MEDIUM = 'medium' 30 | LARGE = 'large' 31 | 32 | 33 | @dataclass 34 | class ThemeColor: 35 | primary: str 36 | face: str 37 | tick: str 38 | grid: str 39 | edge: str 40 | 41 | 42 | @dataclass 43 | class Theme: 44 | dark: ThemeColor 45 | light: ThemeColor 46 | 47 | 48 | themes = Theme( 49 | light=ThemeColor( 50 | primary=settings.INSIGHTS_CHART_LIGHT_PRIMARY_COLOR, 51 | face="#FEFEFE", 52 | tick="#333333", 53 | grid="#333333", 54 | edge="#FFFFFF", 55 | ), 56 | dark=ThemeColor( 57 | primary=settings.INSIGHTS_CHART_DARK_PRIMARY_COLOR, 58 | face="#0d1117", 59 | tick="#FEFEFE", 60 | grid="#FEFEFE", 61 | edge="#0d1117", 62 | ), 63 | ) 64 | 65 | 66 | def get_figsize(size: ChartSize) -> tuple[int, int]: 67 | """Size of charts 68 | 69 | Args: 70 | size (ChartSize): SMALL, MEDIUM, LARGE 71 | 72 | Returns: 73 | tuple[int, int]: return tuple of figure siza 74 | """ 75 | 76 | if size == ChartSize.SMALL: 77 | return (8, 4) 78 | if size == ChartSize.MEDIUM: 79 | return (16, 8) 80 | if size == ChartSize.LARGE: 81 | return (32, 16) 82 | 83 | 84 | def to_bytes_io(fig) -> bytes: 85 | """Render Mathplotlib figure to file-like object""" 86 | flike = io.BytesIO() 87 | fig.savefig(flike, dpi=settings.INSIGHTS_CHART_DPI) 88 | 89 | return flike.getvalue() 90 | 91 | 92 | def to_base64img(fig) -> str: 93 | """Render Mathplotlib figure to Base64 encoded image""" 94 | return base64.b64encode(to_bytes_io(fig)).decode() 95 | 96 | 97 | def prepare_plot(bucket, theme, size: ChartSize = ChartSize.SMALL): 98 | """Default plot options, used for all charts""" 99 | 100 | figsize = get_figsize(size) 101 | 102 | fig, ax = plt.subplots(figsize=figsize) 103 | fig.set_facecolor(theme.face) 104 | ax.tick_params(axis='x', colors=theme.tick) 105 | ax.tick_params(axis='y', colors=theme.tick) 106 | ax.set_facecolor(theme.face) 107 | ax.set_title(bucket.title) 108 | ax.set_ylabel(bucket.ylabel) 109 | ax.set_xlabel(bucket.xlabel) 110 | ax.yaxis.label.set_color(theme.tick) 111 | ax.xaxis.label.set_color(theme.tick) 112 | ax.grid( 113 | linestyle="dotted", 114 | linewidth=0.1, 115 | color=theme.grid, 116 | zorder=-10, 117 | visible=False, 118 | ) 119 | 120 | for spine in ax.spines.values(): 121 | spine.set_edgecolor(theme.edge) 122 | 123 | return fig, ax 124 | 125 | 126 | def render_barchart(yaxis, xaxis, labels, bucket, theme, size) -> str: 127 | """Render barchart""" 128 | theme = getattr(themes, theme) 129 | fig, ax = prepare_plot(bucket, theme, size) 130 | ax.bar(labels, xaxis, color=theme.primary) 131 | 132 | return fig 133 | 134 | 135 | def render_hbarchart(yaxis, xaxis, labels, combined_labels, bucket, theme, size) -> str: 136 | """Render horizontal barchart""" 137 | theme = getattr(themes, theme) 138 | fig, ax = prepare_plot(bucket, theme, size) 139 | bars = ax.barh( 140 | combined_labels, 141 | xaxis, 142 | color=theme.primary, 143 | align='center', 144 | height=0.9, 145 | ) 146 | 147 | for bar in bars: 148 | width = bar.get_width() 149 | label_y_pos = bar.get_y() + bar.get_height() / 2 150 | ax.text(width, label_y_pos, s=f'{width}', va='center', size=4) 151 | 152 | for tick in ax.yaxis.get_major_ticks(): 153 | tick.label1.set_fontsize(6) 154 | tick.label2.set_fontsize(6) 155 | 156 | ax.set_ylabel(bucket.xlabel) 157 | ax.set_xlabel(bucket.ylabel) 158 | 159 | _, xmax = plt.xlim() 160 | plt.subplots_adjust(left=0.3, right=0.9) 161 | plt.xlim(0, xmax + 100) 162 | return fig 163 | 164 | 165 | def render_scatterplot(xaxis, yaxis, bucket, theme, size) -> str: 166 | """Render scatterplot""" 167 | theme = getattr(themes, theme) 168 | 169 | fig, ax = prepare_plot(bucket, theme, size) 170 | ax.scatter(xaxis, yaxis, color=theme.primary) 171 | 172 | return fig 173 | 174 | 175 | def render_timeseries(xaxis, yaxis, bucket, theme, size) -> str: 176 | """Render timeseries""" 177 | theme = getattr(themes, theme) 178 | 179 | fig, ax = prepare_plot(bucket, theme, size) 180 | ax.plot(xaxis, yaxis, '--o', markersize=5, color=theme.primary) 181 | 182 | # Date formatting 183 | ax.fmt_xdata = mdates.DateFormatter(bucket.xformat) 184 | 185 | return fig 186 | 187 | 188 | @sync_to_async 189 | def barchart(bucket: Bucket, theme: str, size: str = ChartSize.SMALL) -> str: 190 | """Barchart""" 191 | values = bucket.values.all() 192 | yvalues = [bucket_value.yvalue for bucket_value in values] 193 | xvalues = [bucket_value.xvalue for bucket_value in values] 194 | labels = [bucket_value.category for bucket_value in values] 195 | 196 | return render_barchart(yvalues, xvalues, labels, bucket, theme, size) 197 | 198 | 199 | @sync_to_async 200 | def hbarchart(bucket: Bucket, theme: str, size: str = ChartSize.MEDIUM) -> str: 201 | """Horizontal Barchart""" 202 | values = bucket.values.all() 203 | yvalues = [bucket_value.yvalue for bucket_value in values] 204 | xvalues = [bucket_value.xvalue for bucket_value in values] 205 | labels = [bucket_value.category for bucket_value in values] 206 | 207 | combined_labels = [ 208 | f"{bucket_value.category[0:30]}..:{int(bucket_value.yvalue)}" 209 | for bucket_value in values 210 | ] 211 | 212 | return render_hbarchart( 213 | yvalues, xvalues, labels, combined_labels, bucket, theme, size 214 | ) 215 | 216 | 217 | @sync_to_async 218 | def scatterplot(bucket: Bucket, theme: str, size: str = ChartSize.SMALL) -> str: 219 | """Scatterplot chart""" 220 | values = bucket.values.all() 221 | yvalues = [bucket_value.yvalue for bucket_value in values] 222 | xvalues = [bucket_value.xvalue for bucket_value in values] 223 | 224 | return render_scatterplot(yvalues, xvalues, bucket, theme, size) 225 | 226 | 227 | @sync_to_async 228 | def timeseries(bucket: Bucket, theme: str, size: str = ChartSize.SMALL) -> str: 229 | """Timeseries chart""" 230 | values = bucket.values.all() 231 | 232 | yvalues = [bucket_value.timestamp for bucket_value in values] 233 | xvalues = [bucket_value.xvalue for bucket_value in values] 234 | 235 | return render_timeseries(yvalues, xvalues, bucket, theme, size) 236 | -------------------------------------------------------------------------------- /django_insights/choices.py: -------------------------------------------------------------------------------- 1 | class BucketType: 2 | TIMESERIES = 1 3 | HISTOGRAM = 2 4 | SCATTERPLOT = 3 5 | BARCHART = 4 6 | HBARCHART = 5 7 | BUCKET_TYPES = ( 8 | (TIMESERIES, 'timeseries'), 9 | (HISTOGRAM, 'histogram'), 10 | (SCATTERPLOT, 'scatterplot'), 11 | (BARCHART, 'barchart'), 12 | (HBARCHART, 'hbarchart'), 13 | ) 14 | -------------------------------------------------------------------------------- /django_insights/database.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | app_labels = {"insights"} 5 | database_entry = "insights" 6 | 7 | 8 | def check_settings(): 9 | if database_entry not in settings.DATABASES: 10 | raise ImproperlyConfigured() 11 | 12 | 13 | class Router: 14 | def db_for_read(self, model, **hints): 15 | if model._meta.app_label in app_labels: 16 | return "insights" 17 | return None 18 | 19 | def db_for_write(self, model, **hints): 20 | if model._meta.app_label in app_labels: 21 | return "insights" 22 | 23 | def allow_relation(self, obj1, obj2, **hints): 24 | if obj1._meta.app_label in app_labels or obj2._meta.app_label in app_labels: 25 | return True 26 | return None 27 | 28 | def allow_migrate(self, db, app_label, **hints): 29 | if app_label in app_labels: 30 | return db == database_entry 31 | return None 32 | -------------------------------------------------------------------------------- /django_insights/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/django_insights/management/__init__.py -------------------------------------------------------------------------------- /django_insights/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/django_insights/management/commands/__init__.py -------------------------------------------------------------------------------- /django_insights/management/commands/collect_insights.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from django_insights.metrics import metrics 4 | from django_insights.registry import registry 5 | 6 | 7 | class Command(BaseCommand): 8 | 9 | """Show all registered audit events""" 10 | 11 | def handle(self, *args, **options): 12 | self.stdout.write(self.style.HTTP_INFO('[Django Insights] - Collect insights')) 13 | 14 | # Auto-discover 15 | registry.autodiscover_insights() 16 | 17 | # Save metrics 18 | metrics.collect(reset=True) 19 | -------------------------------------------------------------------------------- /django_insights/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_insights.choices import BucketType 4 | 5 | 6 | class BucketQuerySet(models.QuerySet): 7 | def timeseries(self): 8 | return self.filter(type=BucketType.TIMESERIES) 9 | 10 | def histograms(self): 11 | return self.filter(type=BucketType.HISTOGRAM) 12 | 13 | def scatterplots(self): 14 | return self.filter(type=BucketType.SCATTERPLOT) 15 | 16 | def barcharts(self): 17 | return self.filter(type=BucketType.BARCHART) 18 | 19 | def hbarcharts(self): 20 | return self.filter(type=BucketType.HBARCHART) 21 | 22 | 23 | class BucketManager(models.Manager): 24 | def get_queryset(self): 25 | return BucketQuerySet(self.model, using=self._db) 26 | 27 | def timeseries(self): 28 | return self.get_queryset().timeseries() 29 | 30 | def histograms(self): 31 | return self.get_queryset().histograms() 32 | 33 | def scatterplots(self): 34 | return self.get_queryset().scatterplots() 35 | 36 | def barcharts(self): 37 | return self.get_queryset().barcharts() 38 | 39 | def hbarcharts(self): 40 | return self.get_queryset().hbarcharts() 41 | -------------------------------------------------------------------------------- /django_insights/metrics.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import importlib 5 | 6 | from django.db import IntegrityError 7 | 8 | from django_insights.choices import BucketType 9 | from django_insights.metrics_types import ( 10 | BarChartAnswer, 11 | BarChartType, 12 | CounterType, 13 | GaugeType, 14 | ScatterPlotAnswer, 15 | ScatterPlotType, 16 | TimeSeriesAnswer, 17 | TimeSeriesType, 18 | ) 19 | from django_insights.models import App, Bucket, BucketValue, Counter, Gauge 20 | from django_insights.registry import registry 21 | from django_insights.utils import rebuild_chart_media_cache 22 | 23 | 24 | class InsightMetrics: 25 | """Auto-generate metrics from multiple apps at once""" 26 | 27 | create_counters: list[Counter] 28 | create_gauges: list[Gauge] 29 | create_bucket_values: list[BucketValue] 30 | 31 | apps: dict[str, App] = {} 32 | 33 | def __init__(self) -> None: 34 | self.create_counters = [] 35 | self.create_gauges = [] 36 | self.create_bucket_values = [] 37 | 38 | def delete_metrics(self) -> None: 39 | """Reset current dataset if metrics are generated""" 40 | Counter.objects.all().delete() 41 | Gauge.objects.all().delete() 42 | Bucket.objects.all().delete() 43 | BucketValue.objects.all().delete() 44 | 45 | def get_memoized_app(self, module: str, label: str = None) -> App: 46 | """Memoize apps so we reduce query count""" 47 | if app := self.apps.get(module): 48 | return app 49 | 50 | # get or create new app 51 | app, _ = App.objects.get_or_create(module=module, defaults={'label': label}) 52 | self.apps.update({module: app}) 53 | 54 | return app 55 | 56 | def get_app(self, func) -> tuple[str, App]: 57 | """ 58 | Get memoized app 59 | 60 | """ 61 | 62 | label = func.__name__ 63 | module = func.__module__ 64 | insights_module = importlib.import_module(module) 65 | 66 | app_label = getattr(insights_module, 'label', None) 67 | app = self.get_memoized_app(module=module, label=app_label) 68 | 69 | return label, app 70 | 71 | def counter(self, question: str = None, desc: str = None): 72 | """ 73 | Decorator to collect Counter metrics 74 | 75 | """ 76 | 77 | def decorator(func): 78 | label, app = self.get_app(func) 79 | 80 | @functools.wraps(func) 81 | def inner(*args, **kwargs): 82 | counter_type = CounterType(value=func(*args, **kwargs)) 83 | counter = Counter( 84 | app=app, 85 | label=label, 86 | value=counter_type.value, 87 | question=question, 88 | desc=desc, 89 | ) 90 | self.create_counters.append(counter) 91 | 92 | registry.register_insight( 93 | label=label, 94 | module=app.module, 95 | question=question, 96 | func=inner, 97 | ) 98 | 99 | return func 100 | 101 | return decorator 102 | 103 | def gauge(self, question: str = None, desc: str = None): 104 | """ 105 | Decorator to collect Gauge metric 106 | 107 | """ 108 | 109 | def decorator(func): 110 | label, app = self.get_app(func) 111 | 112 | @functools.wraps(func) 113 | def inner(*args, **kwargs): 114 | gauge_type = GaugeType(value=func(*args, **kwargs)) 115 | gauge = Gauge( 116 | app=app, 117 | label=label, 118 | value=gauge_type.value, 119 | question=question, 120 | desc=desc, 121 | ) 122 | self.create_gauges.append(gauge) 123 | 124 | registry.register_insight( 125 | label=label, 126 | module=app.module, 127 | question=question, 128 | func=inner, 129 | ) 130 | 131 | return func 132 | 133 | return decorator 134 | 135 | def timeseries( 136 | self, 137 | question: str = None, 138 | desc: str = None, 139 | xlabel: str = None, 140 | xformat: str = None, 141 | ylabel: str = None, 142 | yformat: str = None, 143 | title=None, 144 | ): 145 | """ 146 | Decorator to collect TimeSeries metrics 147 | 148 | """ 149 | 150 | def decorator(func): 151 | label, app = self.get_app(func) 152 | 153 | @functools.wraps(func) 154 | def inner(*args, **kwargs): 155 | results = func(*args, **kwargs) 156 | ts_type = TimeSeriesType( 157 | values=[TimeSeriesAnswer(*result) for result in results] 158 | ) 159 | 160 | bucket = Bucket.objects.create( 161 | app=app, 162 | label=label, 163 | question=question, 164 | desc=desc, 165 | xlabel=xlabel, 166 | xformat=xformat, 167 | ylabel=ylabel, 168 | yformat=yformat, 169 | title=title, 170 | type=BucketType.TIMESERIES, 171 | ) 172 | 173 | for row in ts_type.values: 174 | bucket_value = BucketValue( 175 | timestamp=row.timestamp, xvalue=row.xvalue, bucket=bucket 176 | ) 177 | self.create_bucket_values.append(bucket_value) 178 | 179 | registry.register_insight( 180 | label=label, 181 | module=app.module, 182 | question=question, 183 | func=inner, 184 | ) 185 | 186 | return func 187 | 188 | return decorator 189 | 190 | def scatterplot( 191 | self, 192 | question: str = None, 193 | desc: str = None, 194 | xlabel: str = None, 195 | xformat: str = None, 196 | ylabel: str = None, 197 | yformat: str = None, 198 | title=None, 199 | ): 200 | """ 201 | Decorator to collect Scatterplot metrics 202 | 203 | """ 204 | 205 | def decorator(func): 206 | label, app = self.get_app(func) 207 | 208 | @functools.wraps(func) 209 | def inner(*args, **kwargs): 210 | results = func(*args, **kwargs) 211 | scp_type = ScatterPlotType( 212 | values=[ScatterPlotAnswer(*result) for result in results] 213 | ) 214 | 215 | bucket = Bucket.objects.create( 216 | app=app, 217 | label=label, 218 | question=question, 219 | desc=desc, 220 | xlabel=xlabel, 221 | xformat=xformat, 222 | ylabel=ylabel, 223 | yformat=yformat, 224 | title=title, 225 | type=BucketType.SCATTERPLOT, 226 | ) 227 | 228 | for row in scp_type.values: 229 | bucket_value = BucketValue( 230 | xvalue=row.xvalue, 231 | yvalue=row.yvalue, 232 | category=row.category, 233 | bucket=bucket, 234 | ) 235 | self.create_bucket_values.append(bucket_value) 236 | 237 | registry.register_insight( 238 | label=label, 239 | module=app.module, 240 | question=question, 241 | func=inner, 242 | ) 243 | 244 | return func 245 | 246 | return decorator 247 | 248 | def barchart( 249 | self, 250 | question: str = None, 251 | desc: str = None, 252 | xlabel: str = None, 253 | xformat: str = None, 254 | ylabel: str = None, 255 | yformat: str = None, 256 | title=None, 257 | ): 258 | """ 259 | Decorator to collect Barchart metrics 260 | 261 | """ 262 | 263 | def decorator(func): 264 | label, app = self.get_app(func) 265 | 266 | @functools.wraps(func) 267 | def inner(*args, **kwargs): 268 | results = func(*args, **kwargs) 269 | bar_type = BarChartType( 270 | values=[BarChartAnswer(*result) for result in results] 271 | ) 272 | 273 | bucket = Bucket.objects.create( 274 | app=app, 275 | label=label, 276 | question=question, 277 | desc=desc, 278 | xlabel=xlabel, 279 | xformat=xformat, 280 | ylabel=ylabel, 281 | yformat=yformat, 282 | title=title, 283 | type=BucketType.BARCHART, 284 | ) 285 | 286 | for row in bar_type.values: 287 | bucket_value = BucketValue( 288 | xvalue=row.xvalue, 289 | yvalue=row.yvalue, 290 | category=row.category, 291 | bucket=bucket, 292 | ) 293 | self.create_bucket_values.append(bucket_value) 294 | 295 | registry.register_insight( 296 | label=label, 297 | module=app.module, 298 | question=question, 299 | func=inner, 300 | ) 301 | 302 | return func 303 | 304 | return decorator 305 | 306 | def hbarchart( 307 | self, 308 | question: str = None, 309 | desc: str = None, 310 | xlabel: str = None, 311 | xformat: str = None, 312 | ylabel: str = None, 313 | yformat: str = None, 314 | title=None, 315 | ): 316 | """ 317 | Decorator to collect Horizontal Barchart metrics 318 | 319 | """ 320 | 321 | def decorator(func): 322 | label, app = self.get_app(func) 323 | 324 | @functools.wraps(func) 325 | def inner(*args, **kwargs): 326 | results = func(*args, **kwargs) 327 | bar_type = BarChartType( 328 | values=[BarChartAnswer(*result) for result in results] 329 | ) 330 | 331 | bucket = Bucket.objects.create( 332 | app=app, 333 | label=label, 334 | question=question, 335 | desc=desc, 336 | xlabel=xlabel, 337 | xformat=xformat, 338 | ylabel=ylabel, 339 | yformat=yformat, 340 | title=title, 341 | type=BucketType.HBARCHART, 342 | ) 343 | 344 | for row in bar_type.values: 345 | bucket_value = BucketValue( 346 | xvalue=row.xvalue, 347 | yvalue=row.yvalue, 348 | category=row.category, 349 | bucket=bucket, 350 | ) 351 | self.create_bucket_values.append(bucket_value) 352 | 353 | registry.register_insight( 354 | label=label, 355 | module=app.module, 356 | question=question, 357 | func=inner, 358 | ) 359 | 360 | return func 361 | 362 | return decorator 363 | 364 | def collect(self, reset: bool = False): 365 | """ 366 | Collect insights 367 | 368 | """ 369 | 370 | # Reset metrics 371 | self.delete_metrics() if reset else None 372 | 373 | # Break chart cache 374 | rebuild_chart_media_cache() 375 | 376 | # Collect insights 377 | registry.collect_insights() 378 | 379 | try: 380 | Counter.objects.bulk_create(self.create_counters) 381 | Gauge.objects.bulk_create(self.create_gauges) 382 | BucketValue.objects.bulk_create(self.create_bucket_values) 383 | 384 | except IntegrityError as e: 385 | print( 386 | "Something went wrong, most likely a you've redefined a insights method with the same name." 387 | ) 388 | print("Stacktrace:", e) 389 | exit() 390 | 391 | 392 | metrics = InsightMetrics() 393 | -------------------------------------------------------------------------------- /django_insights/metrics_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from dataclasses import dataclass 5 | from typing import Any, NamedTuple, Optional 6 | 7 | 8 | @dataclass 9 | class CounterType: 10 | value: int 11 | 12 | 13 | @dataclass 14 | class GaugeType: 15 | value: float 16 | 17 | 18 | class TimeSeriesAnswer(NamedTuple): 19 | timestamp: datetime.datetime 20 | xvalue: float 21 | 22 | 23 | @dataclass 24 | class TimeSeriesType: 25 | values: list[TimeSeriesAnswer] 26 | 27 | 28 | class ScatterPlotAnswer(NamedTuple): 29 | xvalue: float 30 | yvalue: float 31 | category: Optional[Any] 32 | 33 | 34 | @dataclass 35 | class ScatterPlotType: 36 | values: list[ScatterPlotAnswer] 37 | 38 | 39 | class BarChartAnswer(NamedTuple): 40 | xvalue: float 41 | yvalue: float 42 | category: Optional[Any] 43 | 44 | 45 | @dataclass 46 | class BarChartType: 47 | values: list[BarChartAnswer] 48 | -------------------------------------------------------------------------------- /django_insights/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-28 07:45 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='App', 18 | fields=[ 19 | ( 20 | 'uuid', 21 | models.UUIDField( 22 | default=uuid.uuid4, 23 | primary_key=True, 24 | serialize=False, 25 | unique=True, 26 | ), 27 | ), 28 | ( 29 | 'module', 30 | models.CharField(db_index=True, max_length=254, unique=True), 31 | ), 32 | ('label', models.CharField(max_length=254, null=True)), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='Bucket', 37 | fields=[ 38 | ( 39 | 'id', 40 | models.AutoField( 41 | auto_created=True, 42 | primary_key=True, 43 | serialize=False, 44 | verbose_name='ID', 45 | ), 46 | ), 47 | ( 48 | 'created_at', 49 | models.DateTimeField( 50 | db_index=True, default=django.utils.timezone.now 51 | ), 52 | ), 53 | ('label', models.CharField(db_index=True, max_length=254, unique=True)), 54 | ('question', models.TextField(blank=True, null=True)), 55 | ('desc', models.TextField(blank=True, null=True)), 56 | ( 57 | 'type', 58 | models.IntegerField( 59 | choices=[ 60 | (1, 'timeseries'), 61 | (2, 'histogram'), 62 | (3, 'scatterplot'), 63 | (4, 'barchart'), 64 | ], 65 | default=1, 66 | ), 67 | ), 68 | ('xlabel', models.CharField(blank=True, max_length=254, null=True)), 69 | ('xformat', models.CharField(blank=True, max_length=254, null=True)), 70 | ('ylabel', models.CharField(blank=True, max_length=254, null=True)), 71 | ('yformat', models.CharField(blank=True, max_length=254, null=True)), 72 | ('title', models.CharField(blank=True, max_length=254, null=True)), 73 | ( 74 | 'app', 75 | models.ForeignKey( 76 | on_delete=django.db.models.deletion.CASCADE, 77 | related_name='buckets', 78 | to='insights.app', 79 | ), 80 | ), 81 | ], 82 | ), 83 | migrations.CreateModel( 84 | name='ExecutionDelta', 85 | fields=[ 86 | ( 87 | 'id', 88 | models.AutoField( 89 | auto_created=True, 90 | primary_key=True, 91 | serialize=False, 92 | verbose_name='ID', 93 | ), 94 | ), 95 | ( 96 | 'executed_at', 97 | models.DateTimeField( 98 | db_index=True, default=django.utils.timezone.now 99 | ), 100 | ), 101 | ('uuid', models.UUIDField(default=uuid.uuid4)), 102 | ], 103 | ), 104 | migrations.CreateModel( 105 | name='Gauge', 106 | fields=[ 107 | ( 108 | 'id', 109 | models.AutoField( 110 | auto_created=True, 111 | primary_key=True, 112 | serialize=False, 113 | verbose_name='ID', 114 | ), 115 | ), 116 | ( 117 | 'created_at', 118 | models.DateTimeField( 119 | db_index=True, default=django.utils.timezone.now 120 | ), 121 | ), 122 | ('label', models.CharField(db_index=True, max_length=254, unique=True)), 123 | ('question', models.TextField(blank=True, null=True)), 124 | ('desc', models.TextField(blank=True, null=True)), 125 | ('value', models.FloatField(default=0.0)), 126 | ( 127 | 'app', 128 | models.ForeignKey( 129 | on_delete=django.db.models.deletion.CASCADE, 130 | related_name='gauges', 131 | to='insights.app', 132 | ), 133 | ), 134 | ], 135 | ), 136 | migrations.CreateModel( 137 | name='Counter', 138 | fields=[ 139 | ( 140 | 'id', 141 | models.AutoField( 142 | auto_created=True, 143 | primary_key=True, 144 | serialize=False, 145 | verbose_name='ID', 146 | ), 147 | ), 148 | ( 149 | 'created_at', 150 | models.DateTimeField( 151 | db_index=True, default=django.utils.timezone.now 152 | ), 153 | ), 154 | ('label', models.CharField(db_index=True, max_length=254, unique=True)), 155 | ('question', models.TextField(blank=True, null=True)), 156 | ('desc', models.TextField(blank=True, null=True)), 157 | ('value', models.IntegerField(default=0)), 158 | ( 159 | 'app', 160 | models.ForeignKey( 161 | on_delete=django.db.models.deletion.CASCADE, 162 | related_name='counters', 163 | to='insights.app', 164 | ), 165 | ), 166 | ], 167 | ), 168 | migrations.CreateModel( 169 | name='BucketValue', 170 | fields=[ 171 | ( 172 | 'id', 173 | models.AutoField( 174 | auto_created=True, 175 | primary_key=True, 176 | serialize=False, 177 | verbose_name='ID', 178 | ), 179 | ), 180 | ( 181 | 'timestamp', 182 | models.DateTimeField(blank=True, db_index=True, null=True), 183 | ), 184 | ('xvalue', models.FloatField(default=0.0)), 185 | ('yvalue', models.FloatField(default=0.0)), 186 | ( 187 | 'category', 188 | models.CharField(db_index=True, max_length=254, null=True), 189 | ), 190 | ( 191 | 'bucket', 192 | models.ForeignKey( 193 | on_delete=django.db.models.deletion.CASCADE, 194 | related_name='values', 195 | to='insights.bucket', 196 | ), 197 | ), 198 | ], 199 | ), 200 | migrations.AddConstraint( 201 | model_name='gauge', 202 | constraint=models.UniqueConstraint( 203 | fields=('app', 'label'), name='gauge_unique_app_label' 204 | ), 205 | ), 206 | migrations.AddConstraint( 207 | model_name='counter', 208 | constraint=models.UniqueConstraint( 209 | fields=('app', 'label'), name='counter_unique_app_label' 210 | ), 211 | ), 212 | migrations.AddConstraint( 213 | model_name='bucketvalue', 214 | constraint=models.UniqueConstraint( 215 | fields=('bucket', 'timestamp'), 216 | name='bucketvalue_unique_bucket_timestamp', 217 | ), 218 | ), 219 | migrations.AddConstraint( 220 | model_name='bucket', 221 | constraint=models.UniqueConstraint( 222 | fields=('app', 'label'), name='bucket_unique_app_label_timestamp' 223 | ), 224 | ), 225 | ] 226 | -------------------------------------------------------------------------------- /django_insights/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/django_insights/migrations/__init__.py -------------------------------------------------------------------------------- /django_insights/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | from django_insights.choices import BucketType 7 | from django_insights.database import database_entry 8 | from django_insights.managers import BucketManager 9 | 10 | 11 | class App(models.Model): 12 | """ 13 | Apps that have insights 14 | 15 | """ 16 | 17 | uuid = models.UUIDField(default=uuid.uuid4, unique=True, primary_key=True) 18 | module = models.CharField(max_length=254, db_index=True, unique=True) 19 | label = models.CharField(max_length=254, null=True) 20 | 21 | @property 22 | def name(self) -> str: 23 | return self.label or self.module 24 | 25 | 26 | class ExecutionDelta(models.Model): 27 | """ 28 | Delta is stored on every run and used for filtering, these values should never change 29 | 30 | """ 31 | 32 | executed_at = models.DateTimeField(db_index=True, default=timezone.now) 33 | uuid = models.UUIDField(default=uuid.uuid4) 34 | 35 | 36 | class MetricModel(models.Model): 37 | created_at = models.DateTimeField(db_index=True, default=timezone.now) 38 | label = models.CharField(max_length=254, db_index=True, unique=True) 39 | 40 | question = models.TextField(blank=True, null=True) 41 | desc = models.TextField(blank=True, null=True) 42 | 43 | class Meta: 44 | abstract = True 45 | 46 | 47 | class Counter(MetricModel): 48 | """ 49 | Simple counter, can incr or decr, label and app are unique 50 | 51 | """ 52 | 53 | value = models.IntegerField(default=0) 54 | app = models.ForeignKey(App, related_name='counters', on_delete=models.CASCADE) 55 | 56 | class Meta: 57 | app_label = database_entry 58 | constraints = [ 59 | models.UniqueConstraint( 60 | fields=['app', 'label'], name='counter_unique_app_label' 61 | ) 62 | ] 63 | 64 | def __str__(self): 65 | return f"Insight Counter: {self.label} = {self.value}" 66 | 67 | 68 | class Gauge(MetricModel): 69 | """ 70 | Simple gauge, can be set or set with delta, label and app are unique 71 | 72 | """ 73 | 74 | value = models.FloatField(default=float(0.0)) 75 | app = models.ForeignKey(App, related_name='gauges', on_delete=models.CASCADE) 76 | 77 | def gauge(self, value=1, delta=False) -> int: 78 | """Set gauge value""" 79 | if not delta: 80 | self.value = value 81 | else: 82 | self.value = self.value + value 83 | 84 | class Meta: 85 | app_label = database_entry 86 | constraints = [ 87 | models.UniqueConstraint( 88 | fields=['app', 'label'], name='gauge_unique_app_label' 89 | ) 90 | ] 91 | 92 | def __str__(self): 93 | return f"Insight Gauge: {self.label} = {self.value}" 94 | 95 | 96 | class Bucket(MetricModel): 97 | app = models.ForeignKey(App, related_name='buckets', on_delete=models.CASCADE) 98 | 99 | type = models.IntegerField( 100 | choices=BucketType.BUCKET_TYPES, default=BucketType.TIMESERIES 101 | ) 102 | 103 | # Bucket specific plot options 104 | xlabel = models.CharField(max_length=254, blank=True, null=True) 105 | xformat = models.CharField(max_length=254, blank=True, null=True) 106 | ylabel = models.CharField(max_length=254, blank=True, null=True) 107 | yformat = models.CharField(max_length=254, blank=True, null=True) 108 | 109 | # title for plot 110 | title = models.CharField(max_length=254, blank=True, null=True) 111 | 112 | @property 113 | def is_timeseries(self) -> bool: 114 | return self.type == BucketType.TIMESERIES 115 | 116 | @property 117 | def is_histogram(self) -> bool: 118 | return self.type == BucketType.HISTOGRAM 119 | 120 | @property 121 | def is_scatterplot(self) -> bool: 122 | return self.type == BucketType.SCATTERPLOT 123 | 124 | @property 125 | def is_barchart(self) -> bool: 126 | return self.type == BucketType.BARCHART 127 | 128 | @property 129 | def is_hbarchart(self) -> bool: 130 | return self.type == BucketType.HBARCHART 131 | 132 | objects = BucketManager() 133 | 134 | class Meta: 135 | app_label = database_entry 136 | constraints = [ 137 | models.UniqueConstraint( 138 | fields=[ 139 | 'app', 140 | 'label', 141 | ], 142 | name='bucket_unique_app_label_timestamp', 143 | ) 144 | ] 145 | 146 | 147 | class BucketValue(models.Model): 148 | """ 149 | Bucket values matrix: used for time-series, histogram and scatterpltos. 150 | Combination of timestamp, label and app are unique. 151 | 152 | """ 153 | 154 | # Timestamp for timeseries 155 | timestamp = models.DateTimeField(db_index=True, blank=True, null=True) 156 | 157 | # Matrix 158 | xvalue = models.FloatField(default=float(0.0)) 159 | yvalue = models.FloatField(default=float(0.0)) 160 | category = models.CharField(db_index=True, max_length=254, null=True) 161 | 162 | bucket = models.ForeignKey(Bucket, related_name='values', on_delete=models.CASCADE) 163 | 164 | class Meta: 165 | app_label = database_entry 166 | constraints = [ 167 | models.UniqueConstraint( 168 | fields=['bucket', 'timestamp'], 169 | name='bucketvalue_unique_bucket_timestamp', 170 | ) 171 | ] 172 | 173 | def __str__(self): 174 | return ( 175 | "Insight Bucket value: " 176 | f" timestamp={self.bucket.label}.{self.timestamp} x={self.xvalue} y={self.yvalue}" 177 | ) 178 | -------------------------------------------------------------------------------- /django_insights/packages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@django-insights/dashboard", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "dev": "tailwindcss -i ./src/input.css -o ../static/insights/css/insights.css --watch", 8 | "bundle": "esbuild src/index.js --minify --bundle --outfile=../static/insights/js/insights.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "esbuild": "^0.17.15", 15 | "preline": "^1.7.0", 16 | "tailwindcss": "^3.2.7" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /django_insights/packages/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.4 2 | 3 | specifiers: 4 | esbuild: ^0.17.15 5 | preline: ^1.7.0 6 | tailwindcss: ^3.2.7 7 | 8 | dependencies: 9 | esbuild: 0.17.15 10 | preline: 1.7.0 11 | tailwindcss: 3.2.7 12 | 13 | packages: 14 | 15 | /@esbuild/android-arm/0.17.15: 16 | resolution: {integrity: sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg==} 17 | engines: {node: '>=12'} 18 | cpu: [arm] 19 | os: [android] 20 | requiresBuild: true 21 | dev: false 22 | optional: true 23 | 24 | /@esbuild/android-arm64/0.17.15: 25 | resolution: {integrity: sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA==} 26 | engines: {node: '>=12'} 27 | cpu: [arm64] 28 | os: [android] 29 | requiresBuild: true 30 | dev: false 31 | optional: true 32 | 33 | /@esbuild/android-x64/0.17.15: 34 | resolution: {integrity: sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ==} 35 | engines: {node: '>=12'} 36 | cpu: [x64] 37 | os: [android] 38 | requiresBuild: true 39 | dev: false 40 | optional: true 41 | 42 | /@esbuild/darwin-arm64/0.17.15: 43 | resolution: {integrity: sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA==} 44 | engines: {node: '>=12'} 45 | cpu: [arm64] 46 | os: [darwin] 47 | requiresBuild: true 48 | dev: false 49 | optional: true 50 | 51 | /@esbuild/darwin-x64/0.17.15: 52 | resolution: {integrity: sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==} 53 | engines: {node: '>=12'} 54 | cpu: [x64] 55 | os: [darwin] 56 | requiresBuild: true 57 | dev: false 58 | optional: true 59 | 60 | /@esbuild/freebsd-arm64/0.17.15: 61 | resolution: {integrity: sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg==} 62 | engines: {node: '>=12'} 63 | cpu: [arm64] 64 | os: [freebsd] 65 | requiresBuild: true 66 | dev: false 67 | optional: true 68 | 69 | /@esbuild/freebsd-x64/0.17.15: 70 | resolution: {integrity: sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ==} 71 | engines: {node: '>=12'} 72 | cpu: [x64] 73 | os: [freebsd] 74 | requiresBuild: true 75 | dev: false 76 | optional: true 77 | 78 | /@esbuild/linux-arm/0.17.15: 79 | resolution: {integrity: sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw==} 80 | engines: {node: '>=12'} 81 | cpu: [arm] 82 | os: [linux] 83 | requiresBuild: true 84 | dev: false 85 | optional: true 86 | 87 | /@esbuild/linux-arm64/0.17.15: 88 | resolution: {integrity: sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA==} 89 | engines: {node: '>=12'} 90 | cpu: [arm64] 91 | os: [linux] 92 | requiresBuild: true 93 | dev: false 94 | optional: true 95 | 96 | /@esbuild/linux-ia32/0.17.15: 97 | resolution: {integrity: sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q==} 98 | engines: {node: '>=12'} 99 | cpu: [ia32] 100 | os: [linux] 101 | requiresBuild: true 102 | dev: false 103 | optional: true 104 | 105 | /@esbuild/linux-loong64/0.17.15: 106 | resolution: {integrity: sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ==} 107 | engines: {node: '>=12'} 108 | cpu: [loong64] 109 | os: [linux] 110 | requiresBuild: true 111 | dev: false 112 | optional: true 113 | 114 | /@esbuild/linux-mips64el/0.17.15: 115 | resolution: {integrity: sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ==} 116 | engines: {node: '>=12'} 117 | cpu: [mips64el] 118 | os: [linux] 119 | requiresBuild: true 120 | dev: false 121 | optional: true 122 | 123 | /@esbuild/linux-ppc64/0.17.15: 124 | resolution: {integrity: sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg==} 125 | engines: {node: '>=12'} 126 | cpu: [ppc64] 127 | os: [linux] 128 | requiresBuild: true 129 | dev: false 130 | optional: true 131 | 132 | /@esbuild/linux-riscv64/0.17.15: 133 | resolution: {integrity: sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA==} 134 | engines: {node: '>=12'} 135 | cpu: [riscv64] 136 | os: [linux] 137 | requiresBuild: true 138 | dev: false 139 | optional: true 140 | 141 | /@esbuild/linux-s390x/0.17.15: 142 | resolution: {integrity: sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg==} 143 | engines: {node: '>=12'} 144 | cpu: [s390x] 145 | os: [linux] 146 | requiresBuild: true 147 | dev: false 148 | optional: true 149 | 150 | /@esbuild/linux-x64/0.17.15: 151 | resolution: {integrity: sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==} 152 | engines: {node: '>=12'} 153 | cpu: [x64] 154 | os: [linux] 155 | requiresBuild: true 156 | dev: false 157 | optional: true 158 | 159 | /@esbuild/netbsd-x64/0.17.15: 160 | resolution: {integrity: sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA==} 161 | engines: {node: '>=12'} 162 | cpu: [x64] 163 | os: [netbsd] 164 | requiresBuild: true 165 | dev: false 166 | optional: true 167 | 168 | /@esbuild/openbsd-x64/0.17.15: 169 | resolution: {integrity: sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w==} 170 | engines: {node: '>=12'} 171 | cpu: [x64] 172 | os: [openbsd] 173 | requiresBuild: true 174 | dev: false 175 | optional: true 176 | 177 | /@esbuild/sunos-x64/0.17.15: 178 | resolution: {integrity: sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ==} 179 | engines: {node: '>=12'} 180 | cpu: [x64] 181 | os: [sunos] 182 | requiresBuild: true 183 | dev: false 184 | optional: true 185 | 186 | /@esbuild/win32-arm64/0.17.15: 187 | resolution: {integrity: sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q==} 188 | engines: {node: '>=12'} 189 | cpu: [arm64] 190 | os: [win32] 191 | requiresBuild: true 192 | dev: false 193 | optional: true 194 | 195 | /@esbuild/win32-ia32/0.17.15: 196 | resolution: {integrity: sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w==} 197 | engines: {node: '>=12'} 198 | cpu: [ia32] 199 | os: [win32] 200 | requiresBuild: true 201 | dev: false 202 | optional: true 203 | 204 | /@esbuild/win32-x64/0.17.15: 205 | resolution: {integrity: sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA==} 206 | engines: {node: '>=12'} 207 | cpu: [x64] 208 | os: [win32] 209 | requiresBuild: true 210 | dev: false 211 | optional: true 212 | 213 | /@nodelib/fs.scandir/2.1.5: 214 | resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} 215 | engines: {node: '>= 8'} 216 | dependencies: 217 | '@nodelib/fs.stat': 2.0.5 218 | run-parallel: 1.2.0 219 | dev: false 220 | 221 | /@nodelib/fs.stat/2.0.5: 222 | resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} 223 | engines: {node: '>= 8'} 224 | dev: false 225 | 226 | /@nodelib/fs.walk/1.2.8: 227 | resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 228 | engines: {node: '>= 8'} 229 | dependencies: 230 | '@nodelib/fs.scandir': 2.1.5 231 | fastq: 1.15.0 232 | dev: false 233 | 234 | /@popperjs/core/2.11.6: 235 | resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==} 236 | dev: false 237 | 238 | /acorn-node/1.8.2: 239 | resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} 240 | dependencies: 241 | acorn: 7.4.1 242 | acorn-walk: 7.2.0 243 | xtend: 4.0.2 244 | dev: false 245 | 246 | /acorn-walk/7.2.0: 247 | resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} 248 | engines: {node: '>=0.4.0'} 249 | dev: false 250 | 251 | /acorn/7.4.1: 252 | resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} 253 | engines: {node: '>=0.4.0'} 254 | hasBin: true 255 | dev: false 256 | 257 | /anymatch/3.1.3: 258 | resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} 259 | engines: {node: '>= 8'} 260 | dependencies: 261 | normalize-path: 3.0.0 262 | picomatch: 2.3.1 263 | dev: false 264 | 265 | /arg/5.0.2: 266 | resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} 267 | dev: false 268 | 269 | /binary-extensions/2.2.0: 270 | resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} 271 | engines: {node: '>=8'} 272 | dev: false 273 | 274 | /braces/3.0.2: 275 | resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} 276 | engines: {node: '>=8'} 277 | dependencies: 278 | fill-range: 7.0.1 279 | dev: false 280 | 281 | /camelcase-css/2.0.1: 282 | resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} 283 | engines: {node: '>= 6'} 284 | dev: false 285 | 286 | /chokidar/3.5.3: 287 | resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} 288 | engines: {node: '>= 8.10.0'} 289 | dependencies: 290 | anymatch: 3.1.3 291 | braces: 3.0.2 292 | glob-parent: 5.1.2 293 | is-binary-path: 2.1.0 294 | is-glob: 4.0.3 295 | normalize-path: 3.0.0 296 | readdirp: 3.6.0 297 | optionalDependencies: 298 | fsevents: 2.3.2 299 | dev: false 300 | 301 | /color-name/1.1.4: 302 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 303 | dev: false 304 | 305 | /cssesc/3.0.0: 306 | resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} 307 | engines: {node: '>=4'} 308 | hasBin: true 309 | dev: false 310 | 311 | /defined/1.0.1: 312 | resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==} 313 | dev: false 314 | 315 | /detective/5.2.1: 316 | resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==} 317 | engines: {node: '>=0.8.0'} 318 | hasBin: true 319 | dependencies: 320 | acorn-node: 1.8.2 321 | defined: 1.0.1 322 | minimist: 1.2.8 323 | dev: false 324 | 325 | /didyoumean/1.2.2: 326 | resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} 327 | dev: false 328 | 329 | /dlv/1.1.3: 330 | resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} 331 | dev: false 332 | 333 | /esbuild/0.17.15: 334 | resolution: {integrity: sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==} 335 | engines: {node: '>=12'} 336 | hasBin: true 337 | requiresBuild: true 338 | optionalDependencies: 339 | '@esbuild/android-arm': 0.17.15 340 | '@esbuild/android-arm64': 0.17.15 341 | '@esbuild/android-x64': 0.17.15 342 | '@esbuild/darwin-arm64': 0.17.15 343 | '@esbuild/darwin-x64': 0.17.15 344 | '@esbuild/freebsd-arm64': 0.17.15 345 | '@esbuild/freebsd-x64': 0.17.15 346 | '@esbuild/linux-arm': 0.17.15 347 | '@esbuild/linux-arm64': 0.17.15 348 | '@esbuild/linux-ia32': 0.17.15 349 | '@esbuild/linux-loong64': 0.17.15 350 | '@esbuild/linux-mips64el': 0.17.15 351 | '@esbuild/linux-ppc64': 0.17.15 352 | '@esbuild/linux-riscv64': 0.17.15 353 | '@esbuild/linux-s390x': 0.17.15 354 | '@esbuild/linux-x64': 0.17.15 355 | '@esbuild/netbsd-x64': 0.17.15 356 | '@esbuild/openbsd-x64': 0.17.15 357 | '@esbuild/sunos-x64': 0.17.15 358 | '@esbuild/win32-arm64': 0.17.15 359 | '@esbuild/win32-ia32': 0.17.15 360 | '@esbuild/win32-x64': 0.17.15 361 | dev: false 362 | 363 | /fast-glob/3.2.12: 364 | resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} 365 | engines: {node: '>=8.6.0'} 366 | dependencies: 367 | '@nodelib/fs.stat': 2.0.5 368 | '@nodelib/fs.walk': 1.2.8 369 | glob-parent: 5.1.2 370 | merge2: 1.4.1 371 | micromatch: 4.0.5 372 | dev: false 373 | 374 | /fastq/1.15.0: 375 | resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} 376 | dependencies: 377 | reusify: 1.0.4 378 | dev: false 379 | 380 | /fill-range/7.0.1: 381 | resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} 382 | engines: {node: '>=8'} 383 | dependencies: 384 | to-regex-range: 5.0.1 385 | dev: false 386 | 387 | /fsevents/2.3.2: 388 | resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 389 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 390 | os: [darwin] 391 | requiresBuild: true 392 | dev: false 393 | optional: true 394 | 395 | /function-bind/1.1.1: 396 | resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} 397 | dev: false 398 | 399 | /glob-parent/5.1.2: 400 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 401 | engines: {node: '>= 6'} 402 | dependencies: 403 | is-glob: 4.0.3 404 | dev: false 405 | 406 | /glob-parent/6.0.2: 407 | resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} 408 | engines: {node: '>=10.13.0'} 409 | dependencies: 410 | is-glob: 4.0.3 411 | dev: false 412 | 413 | /has/1.0.3: 414 | resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} 415 | engines: {node: '>= 0.4.0'} 416 | dependencies: 417 | function-bind: 1.1.1 418 | dev: false 419 | 420 | /is-binary-path/2.1.0: 421 | resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} 422 | engines: {node: '>=8'} 423 | dependencies: 424 | binary-extensions: 2.2.0 425 | dev: false 426 | 427 | /is-core-module/2.11.0: 428 | resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} 429 | dependencies: 430 | has: 1.0.3 431 | dev: false 432 | 433 | /is-extglob/2.1.1: 434 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 435 | engines: {node: '>=0.10.0'} 436 | dev: false 437 | 438 | /is-glob/4.0.3: 439 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 440 | engines: {node: '>=0.10.0'} 441 | dependencies: 442 | is-extglob: 2.1.1 443 | dev: false 444 | 445 | /is-number/7.0.0: 446 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 447 | engines: {node: '>=0.12.0'} 448 | dev: false 449 | 450 | /lilconfig/2.0.6: 451 | resolution: {integrity: sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==} 452 | engines: {node: '>=10'} 453 | dev: false 454 | 455 | /merge2/1.4.1: 456 | resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} 457 | engines: {node: '>= 8'} 458 | dev: false 459 | 460 | /micromatch/4.0.5: 461 | resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} 462 | engines: {node: '>=8.6'} 463 | dependencies: 464 | braces: 3.0.2 465 | picomatch: 2.3.1 466 | dev: false 467 | 468 | /minimist/1.2.8: 469 | resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 470 | dev: false 471 | 472 | /nanoid/3.3.4: 473 | resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} 474 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 475 | hasBin: true 476 | dev: false 477 | 478 | /normalize-path/3.0.0: 479 | resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} 480 | engines: {node: '>=0.10.0'} 481 | dev: false 482 | 483 | /object-hash/3.0.0: 484 | resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} 485 | engines: {node: '>= 6'} 486 | dev: false 487 | 488 | /path-parse/1.0.7: 489 | resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} 490 | dev: false 491 | 492 | /picocolors/1.0.0: 493 | resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} 494 | dev: false 495 | 496 | /picomatch/2.3.1: 497 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 498 | engines: {node: '>=8.6'} 499 | dev: false 500 | 501 | /pify/2.3.0: 502 | resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} 503 | engines: {node: '>=0.10.0'} 504 | dev: false 505 | 506 | /postcss-import/14.1.0_postcss@8.4.21: 507 | resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} 508 | engines: {node: '>=10.0.0'} 509 | peerDependencies: 510 | postcss: ^8.0.0 511 | dependencies: 512 | postcss: 8.4.21 513 | postcss-value-parser: 4.2.0 514 | read-cache: 1.0.0 515 | resolve: 1.22.1 516 | dev: false 517 | 518 | /postcss-js/4.0.1_postcss@8.4.21: 519 | resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} 520 | engines: {node: ^12 || ^14 || >= 16} 521 | peerDependencies: 522 | postcss: ^8.4.21 523 | dependencies: 524 | camelcase-css: 2.0.1 525 | postcss: 8.4.21 526 | dev: false 527 | 528 | /postcss-load-config/3.1.4_postcss@8.4.21: 529 | resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} 530 | engines: {node: '>= 10'} 531 | peerDependencies: 532 | postcss: '>=8.0.9' 533 | ts-node: '>=9.0.0' 534 | peerDependenciesMeta: 535 | postcss: 536 | optional: true 537 | ts-node: 538 | optional: true 539 | dependencies: 540 | lilconfig: 2.0.6 541 | postcss: 8.4.21 542 | yaml: 1.10.2 543 | dev: false 544 | 545 | /postcss-nested/6.0.0_postcss@8.4.21: 546 | resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} 547 | engines: {node: '>=12.0'} 548 | peerDependencies: 549 | postcss: ^8.2.14 550 | dependencies: 551 | postcss: 8.4.21 552 | postcss-selector-parser: 6.0.11 553 | dev: false 554 | 555 | /postcss-selector-parser/6.0.11: 556 | resolution: {integrity: sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==} 557 | engines: {node: '>=4'} 558 | dependencies: 559 | cssesc: 3.0.0 560 | util-deprecate: 1.0.2 561 | dev: false 562 | 563 | /postcss-value-parser/4.2.0: 564 | resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} 565 | dev: false 566 | 567 | /postcss/8.4.21: 568 | resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} 569 | engines: {node: ^10 || ^12 || >=14} 570 | dependencies: 571 | nanoid: 3.3.4 572 | picocolors: 1.0.0 573 | source-map-js: 1.0.2 574 | dev: false 575 | 576 | /preline/1.7.0: 577 | resolution: {integrity: sha512-Zqca6I0Pj5C/yVSCgu3xilwuyNgPUSx1ZgbV5inP9OJmvVqEsOshE7zvo78M927f0vheTFIuinvkfNvAhduq6g==} 578 | dependencies: 579 | '@popperjs/core': 2.11.6 580 | dev: false 581 | 582 | /queue-microtask/1.2.3: 583 | resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 584 | dev: false 585 | 586 | /quick-lru/5.1.1: 587 | resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} 588 | engines: {node: '>=10'} 589 | dev: false 590 | 591 | /read-cache/1.0.0: 592 | resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} 593 | dependencies: 594 | pify: 2.3.0 595 | dev: false 596 | 597 | /readdirp/3.6.0: 598 | resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} 599 | engines: {node: '>=8.10.0'} 600 | dependencies: 601 | picomatch: 2.3.1 602 | dev: false 603 | 604 | /resolve/1.22.1: 605 | resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} 606 | hasBin: true 607 | dependencies: 608 | is-core-module: 2.11.0 609 | path-parse: 1.0.7 610 | supports-preserve-symlinks-flag: 1.0.0 611 | dev: false 612 | 613 | /reusify/1.0.4: 614 | resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} 615 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} 616 | dev: false 617 | 618 | /run-parallel/1.2.0: 619 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 620 | dependencies: 621 | queue-microtask: 1.2.3 622 | dev: false 623 | 624 | /source-map-js/1.0.2: 625 | resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} 626 | engines: {node: '>=0.10.0'} 627 | dev: false 628 | 629 | /supports-preserve-symlinks-flag/1.0.0: 630 | resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 631 | engines: {node: '>= 0.4'} 632 | dev: false 633 | 634 | /tailwindcss/3.2.7: 635 | resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} 636 | engines: {node: '>=12.13.0'} 637 | hasBin: true 638 | dependencies: 639 | arg: 5.0.2 640 | chokidar: 3.5.3 641 | color-name: 1.1.4 642 | detective: 5.2.1 643 | didyoumean: 1.2.2 644 | dlv: 1.1.3 645 | fast-glob: 3.2.12 646 | glob-parent: 6.0.2 647 | is-glob: 4.0.3 648 | lilconfig: 2.0.6 649 | micromatch: 4.0.5 650 | normalize-path: 3.0.0 651 | object-hash: 3.0.0 652 | picocolors: 1.0.0 653 | postcss: 8.4.21 654 | postcss-import: 14.1.0_postcss@8.4.21 655 | postcss-js: 4.0.1_postcss@8.4.21 656 | postcss-load-config: 3.1.4_postcss@8.4.21 657 | postcss-nested: 6.0.0_postcss@8.4.21 658 | postcss-selector-parser: 6.0.11 659 | postcss-value-parser: 4.2.0 660 | quick-lru: 5.1.1 661 | resolve: 1.22.1 662 | transitivePeerDependencies: 663 | - ts-node 664 | dev: false 665 | 666 | /to-regex-range/5.0.1: 667 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 668 | engines: {node: '>=8.0'} 669 | dependencies: 670 | is-number: 7.0.0 671 | dev: false 672 | 673 | /util-deprecate/1.0.2: 674 | resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 675 | dev: false 676 | 677 | /xtend/4.0.2: 678 | resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} 679 | engines: {node: '>=0.4'} 680 | dev: false 681 | 682 | /yaml/1.10.2: 683 | resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} 684 | engines: {node: '>= 6'} 685 | dev: false 686 | -------------------------------------------------------------------------------- /django_insights/packages/src/index.js: -------------------------------------------------------------------------------- 1 | import 'preline'; 2 | 3 | function setCookie(theme) { 4 | document.cookie = `theme=${theme}; path=/; max-age=${60 * 60 * 24 * 14};`; 5 | } 6 | 7 | function getCookie(name) { 8 | let value = `; ${document.cookie}`; 9 | let parts = value.split(`; ${name}=`); 10 | if (parts.length === 2) return parts.pop().split(';').shift(); 11 | } 12 | 13 | function darkMode(theme) { 14 | const element = document.getElementById('html-root'); 15 | if (theme == 'light') { 16 | element.classList.remove('dark'); 17 | } else { 18 | element.classList.add('dark'); 19 | } 20 | } 21 | 22 | function toggleDarkMode() { 23 | const element = document.getElementById('html-root'); 24 | if (element.classList.contains('dark')) { 25 | element.classList.remove('dark'); 26 | return 'light'; 27 | } else { 28 | element.classList.add('dark'); 29 | return 'dark'; 30 | } 31 | } 32 | 33 | document.addEventListener('DOMContentLoaded', function () { 34 | // set theme 35 | const theme = getCookie('theme'); 36 | darkMode(theme); 37 | 38 | const button = document.getElementById('mode-toggle'); 39 | 40 | button.addEventListener('click', (event) => { 41 | const theme = toggleDarkMode(); 42 | setCookie(theme); 43 | window.location.reload(); 44 | }); 45 | 46 | // Enable CSV copy 47 | const copyButtons = document.querySelectorAll('[data-insights-csv-copy]'); 48 | 49 | copyButtons.forEach((btn) => { 50 | btn.addEventListener('click', () => { 51 | const tableId = btn.getAttribute('data-insights-csv-copy'); 52 | copy_csv(tableId); 53 | }); 54 | }); 55 | 56 | // Enable CSV downloads 57 | const downloadButtons = document.querySelectorAll( 58 | '[data-insights-csv-download]' 59 | ); 60 | 61 | downloadButtons.forEach((btn) => { 62 | btn.addEventListener('click', () => { 63 | const tableId = btn.getAttribute('data-insights-csv-download'); 64 | download_csv(tableId); 65 | }); 66 | }); 67 | }); 68 | 69 | function generate_csv(table_id, separator = ',') { 70 | // Select rows from table_id 71 | var rows = document.querySelectorAll(`${table_id} tr`); 72 | // Construct csv 73 | var csv = []; 74 | for (var i = 0; i < rows.length; i++) { 75 | var row = [], 76 | cols = rows[i].querySelectorAll('td, th'); 77 | for (var j = 0; j < cols.length; j++) { 78 | // Clean innertext to remove multiple spaces and jumpline (break csv) 79 | var data = cols[j].innerText 80 | .replace(/(\r\n|\n|\r)/gm, '') 81 | .replace(/(\s\s)/gm, ' '); 82 | // Escape double-quote with double-double-quote (see https://stackoverflow.com/questions/17808511/properly-escape-a-double-quote-in-csv) 83 | data = data.replace(/"/g, '""'); 84 | // Push escaped string 85 | row.push('"' + data + '"'); 86 | } 87 | csv.push(row.join(separator)); 88 | } 89 | return csv.join('\n'); 90 | } 91 | 92 | function download_csv(tableId) { 93 | var csv_string = generate_csv(tableId); 94 | 95 | // Download it 96 | const filename = 97 | 'export_' + 98 | tableId.replace('#', '') + 99 | '_' + 100 | new Date().toLocaleDateString() + 101 | '.csv'; 102 | const link = document.createElement('a'); 103 | link.style.display = 'none'; 104 | link.setAttribute('target', '_blank'); 105 | link.setAttribute( 106 | 'href', 107 | 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv_string) 108 | ); 109 | link.setAttribute('download', filename); 110 | document.body.appendChild(link); 111 | link.click(); 112 | document.body.removeChild(link); 113 | } 114 | 115 | function copy_csv(tableId) { 116 | var csv_string = generate_csv(tableId); 117 | navigator.clipboard.writeText(csv_string).then( 118 | () => { 119 | const dataCopiedMsg = document.getElementById('data-copied-msg'); 120 | dataCopiedMsg.classList.remove('hidden'); 121 | }, 122 | (err) => { 123 | alert('Er ging iets mis', err); 124 | } 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /django_insights/packages/src/input.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.bunny.net/css?family=ubuntu:300,300i,400,400i,500,500i,700,700i); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; -------------------------------------------------------------------------------- /django_insights/packages/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['../templates/**/*.{html,js}', 'node_modules/preline/dist/*.js'], 4 | darkMode: 'class', 5 | theme: { 6 | fontFamily: { 7 | sans: ['Ubuntu', 'sans-serif'], 8 | }, 9 | extend: { 10 | colors: { 11 | background: '#f0f3f6', 12 | foreground: '#0d1117', 13 | gray: { 14 | 50: '#f6f8fa', 15 | 100: '#eaeef2', 16 | 200: '#d0d7de', 17 | 300: '#afb8c1', 18 | 400: '#8c959f', 19 | 500: '#6e7781', 20 | 600: '#57606a', 21 | 700: '#424a53', 22 | 800: '#32383f', 23 | 900: '#24292f', 24 | }, 25 | slate: { 26 | 50: '#f6f8fa', 27 | 100: '#eaeef2', 28 | 200: '#d0d7de', 29 | 300: '#afb8c1', 30 | 400: '#8c959f', 31 | 500: '#6e7781', 32 | 600: '#57606a', 33 | 700: '#424a53', 34 | 800: '#32383f', 35 | 900: '#24292f', 36 | }, 37 | }, 38 | 39 | // neutral: { 40 | // 50: "#f9fafb", 41 | // 100: "#f4f4f5", 42 | // 200: "#e6e6e6", 43 | // 300: "#d4d4d4", 44 | // 400: "#a3a2a3", 45 | // 500: "#737272", 46 | // 600: "#555353", 47 | // 700: "#424040", 48 | // 800: "#292727", 49 | // 900: "#1a1818", 50 | // }, 51 | // blue: { 52 | // DEFAULT: "#355bb6", 53 | // 50: "#eff6ff", 54 | // 100: "#dceafd", 55 | // 200: "#c3dcfb", 56 | // 300: "#9cc6f6", 57 | // 400: "#70a7ec", 58 | // 500: "#5287df", 59 | // 600: "#3f6dcd", 60 | // 700: "#355bb6", 61 | // 800: "#2746a0", 62 | // 900: "#1e3a8a", 63 | // }, 64 | // }, 65 | }, 66 | }, 67 | plugins: [require('preline/plugin')], 68 | }; 69 | -------------------------------------------------------------------------------- /django_insights/pytest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections import namedtuple 3 | from importlib import import_module 4 | from typing import Callable 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | 9 | App = namedtuple('App', ('module')) 10 | 11 | 12 | def register_insights_modules(*args, **kwargs) -> dict[str, Callable]: 13 | """ 14 | Helper function to load insights functions into registry 15 | This way pytest can look them up and call them from a fixture, 16 | it also disables collecting parts with mocks 17 | 18 | Returns: 19 | dict[str, Callable]: registry with functions 20 | """ 21 | from django.apps import apps 22 | 23 | registry: dict[str, Callable] = {} 24 | 25 | for app_config in apps.get_app_configs(): 26 | for module_to_search in args: 27 | # Attempt to import the app's module. 28 | try: 29 | with patch('django_insights.metrics.InsightMetrics.get_app') as get_app: 30 | get_app.return_value = ("foo", App(module="bar")) 31 | mod = import_module("%s.%s" % (app_config.name, module_to_search)) 32 | functions = inspect.getmembers(mod, inspect.isfunction) 33 | registry = {**registry, **dict(functions)} 34 | except Exception: 35 | continue 36 | 37 | return registry 38 | 39 | 40 | registry = register_insights_modules("insights") 41 | 42 | 43 | @pytest.fixture 44 | def insights_query(request) -> Callable | None: 45 | """ 46 | Returns insights_query fixture, this function 47 | resolves wrapped function from registry 48 | 49 | Args: 50 | request (_type_): Current calling context 51 | 52 | Returns: 53 | Callable | None: Return function to call query 54 | """ 55 | if func := registry.get(request.node.name.replace("test_", "")): 56 | return func 57 | 58 | return None 59 | 60 | 61 | def pytest_runtest_setup(item: pytest.Item) -> None: 62 | """ 63 | Add insight fixture to insights marked tests 64 | 65 | Args: 66 | item (pytest.Item): pytest function description 67 | """ 68 | marker = item.get_closest_marker("insights") 69 | if marker is None: 70 | return 71 | item.fixturenames.insert(0, 'insights_query') 72 | item.fixturenames.insert(0, 'insights_config') 73 | -------------------------------------------------------------------------------- /django_insights/registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Callable 4 | 5 | from django.utils.module_loading import autodiscover_modules 6 | from tqdm import tqdm 7 | 8 | 9 | class InsightRegistry: 10 | registered_insights: list[tuple[str, str, str, Callable[[Any], Any]]] = list() 11 | 12 | @staticmethod 13 | def autodiscover_insights(): 14 | autodiscover_modules("insights") 15 | 16 | def register_insight( 17 | self, 18 | label: str, 19 | module: str, 20 | question: str, 21 | func: Callable[[Any], Any], 22 | ): 23 | self.registered_insights.append((module, label, question, func)) 24 | 25 | def collect_insights(self): 26 | progress_iterator = tqdm(self.registered_insights, desc='Collect insights') 27 | 28 | for module, name, question, metric in progress_iterator: 29 | progress_iterator.set_description( 30 | desc=f"Create insights for {module}.{name}", refresh=True 31 | ) 32 | 33 | metric() 34 | 35 | progress_iterator.set_description(desc="Done!") 36 | 37 | 38 | registry = InsightRegistry() 39 | -------------------------------------------------------------------------------- /django_insights/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings as django_settings 4 | 5 | __all__ = ["settings"] 6 | 7 | 8 | class CustomSettings: 9 | """ 10 | Django Insight settings 11 | 12 | """ 13 | 14 | INSIGHTS_APP_NAME: str = "MyApp" 15 | INSIGHTS_CHART_DPI: int = 180 16 | INSIGHTS_DEFAULT_THEME = "dark" 17 | INSIGHTS_CHART_LIGHT_PRIMARY_COLOR = "#2563EB" 18 | INSIGHTS_CHART_DARK_PRIMARY_COLOR = "#BFDBFE" 19 | INSIGHT_CHARTS_USE_MEDIA_CACHE = False 20 | INSIGHT_MEDIA_CACHE_ROOT = None 21 | INSIGHT_DASHBOARD_PERMISSIONS = None 22 | 23 | def __getattribute__(self, name): 24 | try: 25 | return getattr(django_settings, name) 26 | except AttributeError: 27 | return super().__getattribute__(name) 28 | 29 | 30 | settings = CustomSettings() 31 | -------------------------------------------------------------------------------- /django_insights/static/insights/css/pdf.css: -------------------------------------------------------------------------------- 1 | @page { 2 | @top-left { 3 | background: #fff; 4 | content: counter(page); 5 | height: 1cm; 6 | text-align: center; 7 | width: 1cm; 8 | } 9 | @top-center { 10 | background: #fff; 11 | content: ''; 12 | display: block; 13 | height: .05cm; 14 | opacity: .5; 15 | width: 100%; 16 | } 17 | @top-right { 18 | content: string(heading); 19 | font-size: 9pt; 20 | height: 1cm; 21 | vertical-align: middle; 22 | width: 100%; 23 | } 24 | } 25 | @page :blank { 26 | background: #fff; 27 | @top-left { background: none; content: '' } 28 | @top-center { content: none } 29 | @top-right { content: none } 30 | } 31 | @page no-chapter { 32 | @top-left { background: none; content: none } 33 | @top-center { content: none } 34 | @top-right { content: none } 35 | } 36 | @page { 37 | font-family: Ubuntu; 38 | background: #fff; 39 | color: black; 40 | } 41 | 42 | @page chapter { 43 | background: #fff; 44 | margin: 0; 45 | @top-left { content: none } 46 | @top-center { content: none } 47 | @top-right { content: none } 48 | } 49 | 50 | html { 51 | color: black; 52 | font-family: Ubuntu; 53 | font-size: 11pt; 54 | font-weight: 300; 55 | line-height: 1.5; 56 | } 57 | 58 | h1 { 59 | color: black; 60 | font-size: 38pt; 61 | margin: 5cm 2cm 0 2cm; 62 | page: no-chapter; 63 | width: 100%; 64 | } 65 | h2, h3, h4 { 66 | color: black; 67 | font-weight: 400; 68 | } 69 | h2 { 70 | break-before: always; 71 | font-size: 28pt; 72 | font-weight: 700; 73 | string-set: heading content(); 74 | } 75 | h3 { 76 | font-weight: 600; 77 | font-size: 15pt; 78 | } 79 | h4 { 80 | font-size: 13pt; 81 | } 82 | 83 | .chart { 84 | width: 100%; 85 | } 86 | 87 | #cover { 88 | align-content: space-between; 89 | display: flex; 90 | flex-wrap: wrap; 91 | height: 297mm; 92 | } 93 | #cover address { 94 | background: #fff; 95 | flex: 1 50%; 96 | margin: 0 -2cm; 97 | padding: 1cm 0; 98 | white-space: pre-wrap; 99 | } 100 | #cover address:first-of-type { 101 | padding-left: 3cm; 102 | } 103 | #contents { 104 | break-before: right; 105 | break-after: left; 106 | page: no-chapter; 107 | } 108 | #contents h2 { 109 | font-size: 20pt; 110 | font-weight: 400; 111 | margin-bottom: 3cm; 112 | } 113 | #contents h3 { 114 | font-weight: 500; 115 | margin: 3em 0 1em; 116 | } 117 | #contents h3::before { 118 | background: #fff; 119 | content: ''; 120 | display: block; 121 | height: .08cm; 122 | margin-bottom: .25cm; 123 | width: 2cm; 124 | } 125 | #contents ul { 126 | list-style: none; 127 | padding-left: 0; 128 | } 129 | #contents ul li { 130 | border-top: .25pt solid black; 131 | margin: .25cm 0; 132 | padding-top: .25cm; 133 | } 134 | #contents ul li::before { 135 | color: black; 136 | content: '• '; 137 | font-size: 40pt; 138 | line-height: 16pt; 139 | vertical-align: bottom; 140 | } 141 | #contents ul li a { 142 | color: inherit; 143 | text-decoration-line: inherit; 144 | } 145 | #contents ul li a::before { 146 | content: target-text(attr(href)); 147 | } 148 | #contents ul li a::after { 149 | color: black; 150 | content: target-counter(attr(href), page); 151 | float: right; 152 | } 153 | 154 | #columns section { 155 | columns: 2; 156 | column-gap: 1cm; 157 | padding-top: 1cm; 158 | } 159 | #columns section p { 160 | text-align: justify; 161 | } 162 | #columns section p:first-of-type { 163 | font-weight: 700; 164 | } 165 | 166 | #skills h3 { 167 | background: #fff; 168 | margin: 0 -3cm 1cm; 169 | padding: 1cm 1cm 1cm 3cm; 170 | width: 21cm; 171 | } 172 | #skills section { 173 | padding: .5cm 0; 174 | } 175 | #skills section h4 { 176 | margin: 0; 177 | } 178 | #skills section p { 179 | margin-top: 0; 180 | } 181 | 182 | #offers { 183 | display: flex; 184 | flex-wrap: wrap; 185 | justify-content: space-between; 186 | } 187 | #offers h2, #offers h3 { 188 | width: 100%; 189 | } 190 | #offers section { 191 | width: 30%; 192 | } 193 | #offers section h4 { 194 | margin-bottom: 0; 195 | } 196 | #offers section ul { 197 | list-style: none; 198 | margin: 0; 199 | padding-left: 0; 200 | } 201 | #offers section ul li:not(:last-of-type) { 202 | margin: .5cm 0; 203 | } 204 | #offers section p { 205 | background: #fff; 206 | display: block; 207 | font-size: 15pt; 208 | font-weight: 700; 209 | margin-bottom: 0; 210 | padding: .25cm 0; 211 | text-align: center; 212 | } 213 | 214 | #chapter { 215 | align-items: center; 216 | display: flex; 217 | height: 297mm; 218 | justify-content: center; 219 | page: chapter; 220 | } 221 | 222 | #typography section { 223 | display: flex; 224 | flex-wrap: wrap; 225 | margin: 1cm 0; 226 | } 227 | #typography section h4 { 228 | border-top: 1pt solid; 229 | flex: 1 25%; 230 | margin: 0; 231 | } 232 | #typography section h4 + * { 233 | flex: 1 75%; 234 | margin: 0; 235 | padding-left: .5cm; 236 | } 237 | #typography section p { 238 | text-align: justify; 239 | } 240 | #typography section ul { 241 | line-height: 2; 242 | list-style: none; 243 | } 244 | #typography section#small-caps p { 245 | font-variant: small-caps; 246 | } 247 | #typography section#ligatures dl { 248 | display: flex; 249 | flex-wrap: wrap; 250 | } 251 | #typography section#ligatures dl dt { 252 | font-weight: 400; 253 | width: 30%; 254 | } 255 | #typography section#ligatures dl dd { 256 | flex: 1 70%; 257 | margin: 0; 258 | padding: 0; 259 | } 260 | #typography section#ligatures .none { 261 | font-variant-ligatures: none; 262 | } 263 | #typography section#ligatures .common { 264 | font-variant-ligatures: common-ligatures; 265 | } 266 | #typography section#ligatures .discretionary { 267 | font-variant-ligatures: discretionary-ligatures; 268 | } 269 | #typography section#ligatures .contextual { 270 | font-variant-ligatures: contextual; 271 | } 272 | #typography section#numbers dl { 273 | display: flex; 274 | flex-wrap: wrap; 275 | } 276 | #typography section#numbers dl dt { 277 | font-weight: 400; 278 | width: 30%; 279 | } 280 | #typography section#numbers dl dd { 281 | flex: 1 70%; 282 | margin: 0; 283 | padding: 0; 284 | } 285 | #typography section#numbers #fractions { 286 | font-variant-numeric: diagonal-fractions; 287 | } 288 | #typography section#numbers #ordinals { 289 | font-variant-numeric: ordinal; 290 | } 291 | #typography section#numbers #slashed { 292 | font-variant-numeric: slashed-zero; 293 | } 294 | #typography section#numbers #super { 295 | font-variant-position: super; 296 | } 297 | #typography section#numbers #sub { 298 | font-variant-position: sub; 299 | } 300 | #typography section#figures dl { 301 | columns: 4; 302 | } 303 | #typography section#figures dl dt { 304 | font-weight: 400; 305 | } 306 | #typography section#figures dl dd { 307 | display: flex; 308 | margin: 0; 309 | padding: 0; 310 | } 311 | #typography section#figures dl dd ul { 312 | padding: 0 1em 0 0; 313 | } 314 | #typography section#figures #oldstyle { 315 | font-variant-numeric: oldstyle-nums; 316 | } 317 | #typography section#figures #tabular { 318 | font-variant-numeric: tabular-nums; 319 | } 320 | #typography section#figures #old-tabular { 321 | font-variant-numeric: oldstyle-nums tabular-nums; 322 | } 323 | 324 | table { 325 | width: 100%; 326 | font-size: 9px; 327 | padding: 4px; 328 | } 329 | 330 | table thead { 331 | background-color: lightgrey; 332 | padding: 6px; 333 | vertical-align: bottom; 334 | } 335 | 336 | .pagebreak { page-break-before: always;} 337 | 338 | 339 | .counters { 340 | display: flex; 341 | flex-wrap: wrap; 342 | gap: 6px; 343 | } 344 | 345 | .counters > div { 346 | flex: 1 0 22%; 347 | margin: 4px; 348 | padding: 4px; 349 | border: 1px solid black; 350 | border-radius: 4px; 351 | } 352 | 353 | .counters p { 354 | margin: 0; 355 | padding: 4px; 356 | } 357 | 358 | .counters .stats { 359 | display: flex; 360 | padding: 4px; 361 | align-items: center; 362 | } 363 | 364 | .counters .counter { 365 | display: inline-block; 366 | } -------------------------------------------------------------------------------- /django_insights/static/insights/fonts/ubuntu.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/django_insights/static/insights/fonts/ubuntu.ttf -------------------------------------------------------------------------------- /django_insights/templates/insights/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% url 'insights:insight_dashboard' as dashboard_url %} 3 | 4 | 5 | {{ app_name }} - Insights 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 | 36 | 37 | 38 |
    40 |
  1. 41 | {{ app_name }} 42 | 48 | 49 | 50 |
  2. 51 |
  3. {{ app_label }}
  4. 53 |
54 | 65 | 66 |
67 |
68 | 71 | 72 | 109 |
110 | {% block content %} 111 | {% endblock content %} 112 |
113 | 114 | 115 | -------------------------------------------------------------------------------- /django_insights/templates/insights/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "insights/base.html" %} 2 | {% block content %} 3 | 4 |
5 | 6 |
7 | {% for counter in counter_list %} 8 |
9 |
10 |
11 |

{{ counter.question }}

12 | {% if counter.desc %} 13 |
14 |
15 | 21 | 22 | 23 | 24 | 28 |
29 |
30 | {% endif %} 31 |
32 |
33 |

{{ counter.value }}

34 | 35 | x 36 | 37 |
38 |
39 |
40 | {% endfor %} 41 | {% for gauge in gauges %} 42 |
43 |
44 |
45 |

{{ gauge.question }}

46 |
47 |
48 | 54 | 55 | 56 | 57 | 61 |
62 |
63 |
64 |
65 |

{{ gauge.value }}

66 | 67 | % 68 | 69 |
70 |
76 |
77 |
78 |
79 |
80 |
81 | {% endfor %} 82 | 83 |
84 | 85 |
86 | 87 | {% endblock content %} 88 | -------------------------------------------------------------------------------- /django_insights/templates/insights/pdf.html: -------------------------------------------------------------------------------- 1 | {% load static nice_format insight_chart %} 2 | 3 | 4 | 5 | Report {{ app_name }} 6 | 7 | {% if test_mode %} 8 | 9 | {% endif %} 10 | 11 | 12 |
13 |

Insights {{ app_name }}

14 |
15 | {% for app in object_list %} 16 |
17 |

{{ app.name }}

18 |
19 | {% for counter in app.counters.all %} 20 | {% if counter %} 21 | 22 |
23 |

{{ counter.question }}

24 |
25 |

{{ counter.value }}

26 | 27 | x 28 | 29 |
30 |
31 | 32 | {% endif %} 33 | {% endfor %} 34 | {% for gauge in app.gauges.all %} 35 | {% if gauge %} 36 | 37 |
38 |

{{ gauge.question }}

39 |
40 |

{{ gauge.value }}

41 | 42 | % 43 | 44 |
45 |
46 | 47 | {% endif %} 48 | {% endfor %} 49 |
50 |
51 | {% for bucket in app.buckets.timeseries %} 52 | {% if bucket %} 53 |

{{ bucket.question }}

54 |

{{ bucket.desc }}

55 | {% pdf_chart bucket.pk %} 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | {% for bucket_value in bucket.values.all %} 67 | 68 | 69 | 70 | 71 | {% endfor %} 72 | 73 |
{{ bucket.xlabel }}{{ bucket.ylabel }}
{{ bucket_value.timestamp|date:bucket.xformat|clean_str:"%" }}{{ bucket_value.xvalue|nice_float }}
74 | 75 |
76 | {% if not forloop.last %}
{% endif %} 77 | {% endif %} 78 | {% endfor %} 79 | {% for bucket in app.buckets.barcharts %} 80 | {% if bucket %} 81 |

{{ bucket.question }}

82 |

{{ bucket.desc }}

83 | {% pdf_chart bucket.pk %} 84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | {% for bucket_value in bucket.values.all %} 95 | 96 | 97 | 98 | 99 | {% endfor %} 100 | 101 |
{{ bucket.xlabel }}{{ bucket.ylabel }}
{{ bucket_value.category }}{{ bucket_value.xvalue|nice_float }}
102 | 103 |
104 | {% if not forloop.last %}
{% endif %} 105 | {% endif %} 106 | {% endfor %} 107 | {% for bucket in app.buckets.hbarcharts %} 108 | {% if bucket %} 109 |

{{ bucket.question }}

110 |

{{ bucket.desc }}

111 | {% pdf_chart bucket.pk %} 112 |
113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | {% for bucket_value in bucket.values.all %} 123 | 124 | 125 | 126 | 127 | {% endfor %} 128 | 129 |
{{ bucket.xlabel }}{{ bucket.ylabel }}
{{ bucket_value.category }}{{ bucket_value.xvalue|nice_float }}
130 | 131 |
132 | {% if not forloop.last %}
{% endif %} 133 | {% endif %} 134 | {% endfor %} 135 | {% if forloop.last %}
{% endif %} 136 |
137 | {% endfor %} 138 | 139 | 140 | -------------------------------------------------------------------------------- /django_insights/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/django_insights/templatetags/__init__.py -------------------------------------------------------------------------------- /django_insights/templatetags/insight_chart.py: -------------------------------------------------------------------------------- 1 | from asgiref.sync import async_to_sync 2 | from django import template 3 | from django.urls import reverse 4 | from django.utils.safestring import mark_safe 5 | 6 | from django_insights.charts import ( 7 | barchart, 8 | hbarchart, 9 | scatterplot, 10 | timeseries, 11 | to_base64img, 12 | ) 13 | from django_insights.models import Bucket 14 | from django_insights.settings import settings 15 | 16 | register = template.Library() 17 | 18 | 19 | @register.simple_tag 20 | def chart(bucket: Bucket, overlay: str = None): 21 | img_url = reverse("insights:insight_chart", args=[bucket.pk]) 22 | 23 | overlay_attr = "" 24 | if overlay: 25 | overlay_attr = f'data-hs-overlay="#hs-full-screen-modal-{overlay}"' 26 | 27 | return mark_safe( 28 | f""" 29 | {bucket.question} 35 | """ 36 | ) 37 | 38 | 39 | @async_to_sync 40 | async def base64img(bucket: Bucket, override_theme=None): 41 | theme = override_theme or settings.INSIGHTS_DEFAULT_THEME 42 | if bucket.is_timeseries: 43 | fig = await timeseries(bucket, theme=theme) 44 | if bucket.is_scatterplot: 45 | fig = await scatterplot(bucket, theme=theme) 46 | if bucket.is_barchart: 47 | fig = await barchart(bucket, theme=theme) 48 | if bucket.is_hbarchart: 49 | fig = await hbarchart(bucket, theme=theme) 50 | 51 | return to_base64img(fig) 52 | 53 | 54 | @register.simple_tag 55 | def pdf_chart(bucket_id: int): 56 | bucket = Bucket.objects.get(pk=bucket_id) 57 | img_data = base64img(bucket, override_theme="light") 58 | 59 | return mark_safe( 60 | f""" 61 | 65 | """ 66 | ) 67 | -------------------------------------------------------------------------------- /django_insights/templatetags/nice_format.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def clean_str(string, char): 8 | return string.replace(char, '') 9 | 10 | 11 | @register.filter 12 | def nice_float(string): 13 | if isinstance(string, float): 14 | return f'{round(string):,}'.replace(',', '.') 15 | return string 16 | -------------------------------------------------------------------------------- /django_insights/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from django_insights import views 4 | 5 | app_name = "insights" 6 | 7 | urlpatterns = [ 8 | path( 9 | 'app//', 10 | views.InsightsAppView.as_view(), 11 | name="insight_app", 12 | ), 13 | path( 14 | 'pdf/test/', 15 | views.InsightsPDFTestView.as_view(), 16 | name="insight_pdf_test", 17 | ), 18 | path( 19 | 'pdf/', 20 | views.InsightsPDFView.as_view(), 21 | name="insight_pdf", 22 | ), 23 | path( 24 | 'charts//', 25 | views.InsightsChartView.as_view(), 26 | name="insight_chart", 27 | ), 28 | path( 29 | '', 30 | views.InsightsDashboardView.as_view(), 31 | name="insight_dashboard", 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /django_insights/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from typing import Any, Callable 4 | 5 | from django.conf import settings 6 | from django.db.backends.base.base import BaseDatabaseWrapper 7 | from django.db.backends.signals import connection_created 8 | 9 | 10 | class DjangoReadOnlyError(Exception): 11 | pass 12 | 13 | 14 | def rebuild_chart_media_cache() -> None: 15 | """ 16 | Delete and recreate cache if cache is enabled and cache dir exists 17 | """ 18 | if settings.INSIGHT_CHARTS_USE_MEDIA_CACHE and os.path.exists( 19 | settings.INSIGHT_MEDIA_CACHE_ROOT 20 | ): 21 | shutil.rmtree(settings.INSIGHT_MEDIA_CACHE_ROOT) 22 | os.mkdir(settings.INSIGHT_MEDIA_CACHE_ROOT) 23 | 24 | 25 | def should_block(sql: str) -> bool: 26 | return not sql.lstrip(" \n(").startswith( 27 | ( 28 | "EXPLAIN ", 29 | "PRAGMA ", 30 | "ROLLBACK TO SAVEPOINT ", 31 | "RELEASE SAVEPOINT ", 32 | "SAVEPOINT ", 33 | "SELECT ", 34 | "SET ", 35 | ) 36 | ) and sql not in ("BEGIN", "COMMIT", "ROLLBACK") 37 | 38 | 39 | def blocker( 40 | execute: Callable[[str, str, bool, dict[str, Any]], Any], 41 | sql: str, 42 | params: str, 43 | many: bool, 44 | context: dict[str, Any], 45 | ) -> Any: 46 | if should_block(sql): 47 | msg = "Write queries are currently disabled. Metrics MUST be read_only" 48 | raise DjangoReadOnlyError(msg) 49 | return execute(sql, params, many, context) 50 | 51 | 52 | def install_hook(connection: BaseDatabaseWrapper, **kwargs: object) -> None: 53 | if blocker not in connection.execute_wrappers: # pragma: no branch 54 | connection.execute_wrappers.insert(0, blocker) 55 | 56 | 57 | class ReadOnly: 58 | def __init__(self, read_only_metric): 59 | self.read_only_metric = read_only_metric 60 | 61 | def __enter__(self): 62 | connection_created.connect(install_hook) 63 | return self.read_only_metric 64 | 65 | def __exit__(*args, **kwargs): 66 | connection_created.disconnect(install_hook) 67 | 68 | 69 | read_only_mode = ReadOnly 70 | -------------------------------------------------------------------------------- /django_insights/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os 5 | from typing import Any, Callable 6 | 7 | from asgiref.sync import sync_to_async 8 | from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin 9 | from django.db.models.query import QuerySet 10 | from django.http import HttpResponse 11 | from django.utils.decorators import classonlymethod 12 | from django.views.generic import DetailView, ListView, View 13 | from django_weasyprint import WeasyTemplateResponseMixin 14 | 15 | from django_insights.charts import ( 16 | barchart, 17 | hbarchart, 18 | scatterplot, 19 | timeseries, 20 | to_bytes_io, 21 | ) 22 | from django_insights.models import App, Bucket, Counter, Gauge 23 | from django_insights.settings import settings 24 | 25 | 26 | class CustomPermissionMixin(UserPassesTestMixin): 27 | 28 | """ 29 | Run following permisssion 30 | 31 | class IsSomeAdminUser: 32 | def has_permission(self, request): 33 | return 34 | """ 35 | 36 | def run_permissions(self, permissions: list[Callable]) -> list[bool]: 37 | """ 38 | Run all permission checks from INSIGHT_DASHBOARD_PERMISSIONS settings 39 | and return output in array 40 | """ 41 | return [ 42 | permission().has_permission(request=self.request) 43 | for permission in permissions 44 | if hasattr(permission(), 'has_permission') 45 | ] 46 | 47 | def test_func(self): 48 | return ( 49 | all(self.run_permissions(settings.INSIGHT_DASHBOARD_PERMISSIONS)) 50 | if settings.INSIGHT_DASHBOARD_PERMISSIONS 51 | else True 52 | ) 53 | 54 | 55 | class InsightAppMixin( 56 | LoginRequiredMixin, 57 | CustomPermissionMixin, 58 | View, 59 | ): 60 | apps: list[App] = [] 61 | 62 | def get_apps(self) -> QuerySet[App]: 63 | if not self.apps: 64 | self.apps = App.objects.all() 65 | return self.apps 66 | 67 | def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 68 | context = super().get_context_data(**kwargs) 69 | context.update( 70 | {'app_name': settings.INSIGHTS_APP_NAME, 'apps': self.get_apps()} 71 | ) 72 | return context 73 | 74 | 75 | class InsightsAppView(InsightAppMixin, DetailView): 76 | model = App 77 | slug_field = 'uuid' 78 | slug_url_kwarg = 'app_uuid' 79 | template_name = "insights/app.html" 80 | 81 | def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 82 | app = self.get_object() 83 | 84 | context = super().get_context_data(**kwargs) 85 | context.update({'app_label': app.name}) 86 | return context 87 | 88 | 89 | class InsightsDashboardView(InsightAppMixin, ListView): 90 | template_name = "insights/dashboard.html" 91 | 92 | def get_queryset(self) -> list[Any]: 93 | # Get all metrics in one query? 94 | return Counter.objects.all() 95 | 96 | def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 97 | context = super().get_context_data(**kwargs) 98 | context.update({'gauges': Gauge.objects.all(), 'app_label': 'Dashboard'}) 99 | return context 100 | 101 | 102 | @sync_to_async 103 | def get_bucket(bucket_id: int) -> Bucket: 104 | """Get bucket from async context""" 105 | return Bucket.objects.get(pk=bucket_id) 106 | 107 | 108 | class InsightsChartView(LoginRequiredMixin, View): 109 | @classonlymethod 110 | def as_view(cls, **initkwargs): 111 | view = super().as_view(**initkwargs) 112 | view._is_coroutine = asyncio.coroutines._is_coroutine 113 | return view 114 | 115 | async def get(self, request, bucket_id): 116 | theme = self.request.COOKIES.get('theme') or settings.INSIGHTS_DEFAULT_THEME 117 | bucket: Bucket = await get_bucket(bucket_id) 118 | 119 | filename: str = ( 120 | f"{settings.INSIGHT_MEDIA_CACHE_ROOT}/{bucket.type}-{bucket.pk}-{theme}.png" 121 | ) 122 | 123 | if os.path.exists(filename) and settings.INSIGHT_CHARTS_USE_MEDIA_CACHE: 124 | with open(filename, 'rb') as cached_image: 125 | buffer = cached_image.read() 126 | 127 | return HttpResponse(buffer, content_type='image/png') 128 | 129 | fig = None 130 | 131 | if bucket.is_timeseries: 132 | fig = await timeseries(bucket, theme=theme) 133 | if bucket.is_scatterplot: 134 | fig = await scatterplot(bucket, theme=theme) 135 | if bucket.is_barchart: 136 | fig = await barchart(bucket, theme=theme) 137 | if bucket.is_hbarchart: 138 | fig = await hbarchart(bucket, theme=theme) 139 | 140 | buffer = to_bytes_io(fig) 141 | 142 | if settings.INSIGHT_CHARTS_USE_MEDIA_CACHE: 143 | with open(filename, 'wb') as cached_image: 144 | cached_image.write(buffer) 145 | 146 | return HttpResponse(buffer, content_type='image/png') 147 | 148 | 149 | class InsightsPDFView(WeasyTemplateResponseMixin, InsightAppMixin, ListView): 150 | """Render PDF""" 151 | 152 | queryset = App.objects.all() 153 | 154 | pdf_stylesheets = [ 155 | settings.STATIC_ROOT + 'insights/css/pdf.css', 156 | ] 157 | 158 | template_name = 'insights/pdf.html' 159 | pdf_attachment = False 160 | pdf_filename = 'foo.pdf' 161 | 162 | 163 | class InsightsPDFTestView(InsightAppMixin, ListView): 164 | """Render PDF HTML as test""" 165 | 166 | queryset = App.objects.all() 167 | 168 | pdf_stylesheets = [ 169 | settings.STATIC_ROOT + 'insights/css/pdf.css', 170 | ] 171 | 172 | def get_template_names(self): 173 | return ['insights/pdf.html'] 174 | 175 | def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 176 | data = super().get_context_data(**kwargs) 177 | 178 | data.update( 179 | { 180 | 'test_mode': True, 181 | 'test_stylesheet': 'insights/css/pdf.css', 182 | } 183 | ) 184 | 185 | return data 186 | -------------------------------------------------------------------------------- /docs/alternatives.md: -------------------------------------------------------------------------------- 1 | # Alternatives 2 | -------------------------------------------------------------------------------- /docs/assets/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/docs/assets/images/banner.png -------------------------------------------------------------------------------- /docs/assets/images/screen_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/docs/assets/images/screen_1.png -------------------------------------------------------------------------------- /docs/assets/images/screen_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/docs/assets/images/screen_2.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Django Insights 2 | 3 | ## Django Insights: Overview and Usage 4 | 5 | Django Insights is a Django app that provides a easy way to generate application insights and store them in a SQLite database. These insights can be used to inform decisions about application design and to identify trends over time. 6 | 7 | Examples of the types of insights you can gather include: 8 | 9 | - number of users 10 | - number of users invited in the past year 11 | - number of reports generated per day 12 | - number of messages sent on Wednesdays 13 | 14 | While Django Insights can be used gather insights from your current application state, it is not suitable for tracking real-time metrics such as: 15 | 16 | - number of GET requests for a specific URL per second 17 | - real-time profit target percentage 18 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Installing with: 4 | 5 | ```bash 6 | pip install 'django-insights' 7 | ``` 8 | 9 | ## Usage 10 | 11 | First create a `insights.py` file in your app directory, for example: 12 | 13 | ```bash 14 | project 15 | └── testapp 16 | └── insights.py 17 | ``` 18 | 19 | Each app can have it's own `insights.py` file, these files are auto-discovered by Django Insights, so at any given location it would pick up your metrics. 20 | 21 | In these insights files you write out any metric you would like to track. Each metric starts with a question and some values to store. Below is a example of the `@metrics.counter` function: 22 | 23 | ```python 24 | # project/testapp/insights.py 25 | from django_insights.metrics import metrics 26 | from project.testapp.models import Author 27 | 28 | label = "Bookstore" 29 | 30 | @metrics.counter(question="How many authors are there?") 31 | def count_authors() -> int: 32 | return Author.objects.count() 33 | 34 | ``` 35 | 36 | Insight apps can have a `label`, this is used in the dashboard or can be read from `insights_app` table later on. 37 | 38 | ### Settings 39 | 40 | Add django_insights package, insights database and router to your settings 41 | 42 | ```python 43 | 44 | INSTALLED_APPS = [ 45 | ... 46 | "django_insights", 47 | ] 48 | 49 | 50 | DATABASES = { 51 | ... 52 | "insights": { 53 | "ENGINE": "django.db.backends.sqlite3", 54 | "NAME": os.path.join(BASE_DIR,"db/insights.db") 55 | }, 56 | ... 57 | } 58 | 59 | DATABASE_ROUTERS = ['django_insights.database.Router'] 60 | 61 | ``` 62 | 63 | Note: please make sure you exclude the database in your `.gitignore` file 64 | 65 | ### Migrate database 66 | 67 | ```bash 68 | workon myapp 69 | python manage.py migrate insights --database=insights 70 | ``` 71 | 72 | ### Collect your insights 73 | 74 | ```bash 75 | python manage.py collect_insights 76 | ``` 77 | 78 | You now have a database containing all insights from your application. 79 | 80 | Note: You need to run the `python manage.py collect_insights` command each time you want to update data on your dashboard. In production, you can setup a cron job to call this command at regular intervals depending on your use case. 81 | 82 | 83 | You can inspect this database yourself with `sqlite3 db/insights.db` - or - you can use the Django Insights dashboard. 84 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | **The MIT License (MIT)** 4 | 5 | Copyright (c) 2023 terminalkitten and individual contributors. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 11 | of the Software, and to permit persons to whom the Software is furnished to do 12 | so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | [data-md-color-scheme="slate"] { 2 | --md-hue: 210; 3 | } -------------------------------------------------------------------------------- /docs/types.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | 3 | There are currently 4 types of metrics that can be collected with Django Insights: 4 | 5 | ## Counter 6 | 7 | ## Gauge 8 | 9 | ## Timeseries 10 | 11 | ## Scatterplot 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Define the Django Insights management entry.""" 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings.dev") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django Insights 2 | site_url: https://github.com/terminalkitten/django-insights/ 3 | site_author: terminalkitten 4 | site_description: >- 5 | Simple and easy insights in your Django app 6 | 7 | # Repository 8 | repo_name: terminalkitten/django-insights 9 | repo_url: https://github.com/terminalkitten/django-insights 10 | 11 | # Theme 12 | 13 | theme: 14 | name: material 15 | palette: 16 | accent: orange 17 | primary: orange 18 | scheme: slate 19 | font: 20 | text: Roboto 21 | icon: 22 | logo: octicons/pulse-16 23 | features: 24 | - navigation.footer 25 | 26 | # Custom CSS 27 | extra_css: 28 | - stylesheets/extra.css 29 | 30 | # Pages 31 | nav: 32 | - Home: index.md 33 | - Getting started: 34 | - Installation: installation.md 35 | - Alternatives: alternatives.md 36 | - License: license.md 37 | - Insights: 38 | - Types: types.md 39 | 40 | # Extra 41 | extra: 42 | generator: false 43 | copyright: Copyright © 2023 terminalkitten 44 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/__init__.py -------------------------------------------------------------------------------- /project/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/settings/__init__.py -------------------------------------------------------------------------------- /project/settings/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import django 6 | 7 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 8 | 9 | DEBUG = True 10 | TEMPLATE_DEBUG = DEBUG 11 | 12 | SECRET_KEY = "NOTASECRET" 13 | 14 | 15 | DATABASES = { 16 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db/testapp.db"}, 17 | "insights": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db/insights.db"}, 18 | } 19 | 20 | DATABASE_ROUTERS = ['django_insights.database.Router'] 21 | 22 | CACHES = { 23 | "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, 24 | } 25 | 26 | ALLOWED_HOSTS: list[str] = [] 27 | 28 | INSTALLED_APPS = [ 29 | "django.contrib.auth", 30 | "django.contrib.contenttypes", 31 | "django.contrib.staticfiles", 32 | "project.testapp", 33 | "project.testapp.users", 34 | "django_insights", 35 | ] 36 | 37 | MIDDLEWARE_CLASSES = ( 38 | "django.middleware.common.CommonMiddleware", 39 | "django.middleware.csrf.CsrfViewMiddleware", 40 | "django.contrib.sessions.middleware.SessionMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | ) 45 | 46 | STATIC_URL = 'static/' 47 | STATIC_ROOT = os.path.join(BASE_DIR, 'static/') 48 | STATICFILES_FINDERS = ( 49 | 'django.contrib.staticfiles.finders.FileSystemFinder', 50 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 51 | ) 52 | 53 | MEDIA_URL = 'media/' 54 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') 55 | 56 | 57 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 58 | 59 | AUTH_USER_MODEL = "users.AppUser" 60 | 61 | ROOT_URLCONF = "project.urls" 62 | LANGUAGE_CODE = "en-us" 63 | TIME_ZONE = "UTC" 64 | USE_I18N = True 65 | 66 | if django.VERSION < (4, 0): 67 | USE_L10N = True 68 | 69 | TEMPLATES = [ 70 | { 71 | "BACKEND": "django.template.backends.django.DjangoTemplates", 72 | "DIRS": [], 73 | "APP_DIRS": True, 74 | "OPTIONS": { 75 | "context_processors": [ 76 | "django.template.context_processors.debug", 77 | "django.template.context_processors.request", 78 | "django.contrib.auth.context_processors.auth", 79 | "django.contrib.messages.context_processors.messages", 80 | ] 81 | }, 82 | } 83 | ] 84 | 85 | USE_TZ = True 86 | 87 | 88 | # Django Insights settings 89 | 90 | # Custom app name 91 | INSIGHTS_APP_NAME = "Finq" 92 | 93 | # Quality of chart images 94 | INSIGHTS_CHART_DPI = 180 95 | 96 | # Insight cache 97 | INSIGHT_MEDIA_CACHE_ROOT = MEDIA_ROOT 98 | 99 | # Change primary color 100 | INSIGHTS_CHART_LIGHT_PRIMARY_COLOR = "#2563EB" 101 | INSIGHTS_CHART_DARK_PRIMARY_COLOR = "#BFDBFE" 102 | -------------------------------------------------------------------------------- /project/settings/dev.py: -------------------------------------------------------------------------------- 1 | from project.settings.base import * # noqa 2 | -------------------------------------------------------------------------------- /project/settings/test.py: -------------------------------------------------------------------------------- 1 | from project.settings.base import * # noqa 2 | 3 | DATABASES = { 4 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, 5 | "insights": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, 6 | } 7 | -------------------------------------------------------------------------------- /project/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/testapp/__init__.py -------------------------------------------------------------------------------- /project/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = 'project.testapp' 6 | verbose_name = 'Django Insights TestApp' 7 | default_auto_field = 'django.db.models.BigAutoField' 8 | -------------------------------------------------------------------------------- /project/testapp/insights.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Any 5 | 6 | from django.db.models import Avg, Case, Count, Value, When 7 | from django.db.models.functions import Length, TruncMonth, TruncYear 8 | 9 | from django_insights.metrics import metrics 10 | from project.testapp.models import Author, Book 11 | 12 | label = "Books" 13 | 14 | 15 | @metrics.counter( 16 | question="How many authors are there in our store?", 17 | desc="Number of authors we sell books for in our store", 18 | ) 19 | def count_authors() -> int: 20 | return Author.objects.count() 21 | 22 | 23 | @metrics.counter(question="How many books are there?") 24 | def count_books() -> int: 25 | return Book.objects.count() 26 | 27 | 28 | @metrics.counter(question="Books with title longer than 20 chars?") 29 | def count_books_title_gt_20() -> int: 30 | return ( 31 | Book.objects.annotate(title_len=Length('title')) 32 | .filter(title_len__gt=20) 33 | .count() 34 | ) 35 | 36 | 37 | @metrics.counter(question="Books with title less than 10 chars?") 38 | def count_books_title_lt_10() -> int: 39 | return ( 40 | Book.objects.annotate(title_len=Length('title')) 41 | .filter(title_len__lt=10) 42 | .count() 43 | ) 44 | 45 | 46 | @metrics.counter(question="Authors with two or more books?") 47 | def count_authors_with_two_or_more_books() -> int: 48 | return ( 49 | Author.objects.prefetch_related('books') 50 | .annotate(total_books=Count('books')) 51 | .filter(total_books__gte=2) 52 | .count() 53 | ) 54 | 55 | 56 | @metrics.counter(question="Authors without books?") 57 | def count_authors_without_books() -> int: 58 | return ( 59 | Author.objects.prefetch_related('books') 60 | .annotate(total_books=Count('books')) 61 | .filter(total_books=0) 62 | .count() 63 | ) 64 | 65 | 66 | @metrics.counter(question="Authors with name longer than 20 chars?") 67 | def count_authors_name_gt_20() -> int: 68 | return ( 69 | Author.objects.annotate(name_len=Length('name')).filter(name_len__gt=20).count() 70 | ) 71 | 72 | 73 | @metrics.gauge(question="Average book(s) per author?") 74 | def avg_books_per_author() -> int: 75 | avg_total_books = ( 76 | Author.objects.prefetch_related('books') 77 | .annotate(total_books=Count('books')) 78 | .aggregate(Avg('total_books')) 79 | .get('total_books__avg') 80 | ) 81 | 82 | return avg_total_books 83 | 84 | 85 | @metrics.timeseries( 86 | question="Num of books created per month?", 87 | desc="How many books are added each month, since the opening of our store", 88 | xlabel="Month", 89 | xformat='%m', 90 | ylabel="Num of books", 91 | ) 92 | def num_of_books_per_month() -> list[tuple[datetime, int]]: 93 | return ( 94 | Book.objects.all() 95 | .annotate(month=TruncMonth('created')) 96 | .values('month') 97 | .filter(month__isnull=False) 98 | .annotate(total=Count('pk')) 99 | .values_list('month', 'total') 100 | .order_by('month') 101 | ) 102 | 103 | 104 | @metrics.timeseries( 105 | question="Num of books created per year?", 106 | xlabel="Year", 107 | xformat='%Y', 108 | ylabel="Num of books", 109 | ) 110 | def num_of_books_per_year() -> list[tuple[datetime, int]]: 111 | return ( 112 | Book.objects.all() 113 | .annotate(year=TruncYear('created')) 114 | .values('year') 115 | .filter(year__isnull=False) 116 | .annotate(total=Count('pk')) 117 | .values_list('year', 'total') 118 | .order_by('year') 119 | ) 120 | 121 | 122 | @metrics.scatterplot( 123 | question="Num of books by age of author?", 124 | xlabel="Age", 125 | ylabel="Num of books", 126 | ) 127 | def author_age_vs_num_of_books() -> list[tuple[float, float, Any]]: 128 | return ( 129 | Author.objects.values('age') 130 | .annotate(num_of_books=Count('books'), category=Value("author")) 131 | .values_list('num_of_books', 'age', 'category') 132 | ) 133 | 134 | 135 | @metrics.barchart( 136 | question="Num of books by gender of author?", 137 | xlabel="Gender", 138 | ylabel="Num of books", 139 | ) 140 | def author_gender_vs_num_of_books() -> list[tuple[float, float, str]]: 141 | return ( 142 | Author.objects.values('gender') 143 | .annotate( 144 | num_of_books=Count('books'), 145 | gender_category=Case( 146 | When(gender=1, then=Value('Male')), 147 | When(gender=2, then=Value('Female')), 148 | ), 149 | ) 150 | .values_list('num_of_books', 'gender', 'gender_category') 151 | ) 152 | -------------------------------------------------------------------------------- /project/testapp/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/testapp/management/__init__.py -------------------------------------------------------------------------------- /project/testapp/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/testapp/management/commands/__init__.py -------------------------------------------------------------------------------- /project/testapp/management/commands/seed_db.py: -------------------------------------------------------------------------------- 1 | from random import choice, randrange 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.utils import timezone 5 | from faker import Faker 6 | 7 | from project.testapp.models import Author, Book 8 | from project.testapp.users.models import AppUser 9 | 10 | fake = Faker() 11 | 12 | 13 | class Command(BaseCommand): 14 | 15 | """Seed TestApp database""" 16 | 17 | def handle(self, *args, **options): 18 | self.stdout.write(self.style.HTTP_INFO('[TestApp] - Database seed')) 19 | 20 | AppUser.objects.create(email="user@example.com") 21 | 22 | Author.objects.all().delete() 23 | Book.objects.all().delete() 24 | 25 | authors = [] 26 | 27 | names = [fake.unique.name() for i in range(2000)] 28 | titles = [fake.sentence(nb_words=randrange(30)) for i in range(5000)] 29 | 30 | for name in names: 31 | author_created = fake.date_time_between_dates( 32 | datetime_start='-10y', 33 | datetime_end='now', 34 | tzinfo=timezone.get_current_timezone(), 35 | ) 36 | authors.append( 37 | Author.objects.create( 38 | created=author_created, 39 | name=name, 40 | age=randrange(80), 41 | gender=fake.random.choice((1, 2)), 42 | ) 43 | ) 44 | 45 | for title in titles: 46 | book_created = fake.date_time_between_dates( 47 | datetime_start='-10y', 48 | datetime_end='now', 49 | tzinfo=timezone.get_current_timezone(), 50 | ) 51 | 52 | Book.objects.create( 53 | created=book_created, author=choice(authors), title=title 54 | ) 55 | -------------------------------------------------------------------------------- /project/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-28 07:45 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Author', 15 | fields=[ 16 | ( 17 | 'id', 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name='ID', 23 | ), 24 | ), 25 | ('created', models.DateTimeField(verbose_name='created')), 26 | ( 27 | 'modified', 28 | models.DateTimeField(auto_now=True, verbose_name='modified'), 29 | ), 30 | ('gender', models.IntegerField(choices=[('M', 1), ('F', 2)])), 31 | ('name', models.CharField(max_length=32, unique=True)), 32 | ('age', models.IntegerField()), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='Book', 37 | fields=[ 38 | ( 39 | 'id', 40 | models.BigAutoField( 41 | auto_created=True, 42 | primary_key=True, 43 | serialize=False, 44 | verbose_name='ID', 45 | ), 46 | ), 47 | ('created', models.DateTimeField(verbose_name='created')), 48 | ( 49 | 'modified', 50 | models.DateTimeField(auto_now=True, verbose_name='modified'), 51 | ), 52 | ('title', models.CharField(max_length=128)), 53 | ( 54 | 'author', 55 | models.ForeignKey( 56 | on_delete=django.db.models.deletion.CASCADE, 57 | related_name='books', 58 | to='testapp.author', 59 | ), 60 | ), 61 | ], 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /project/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /project/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Author(models.Model): 5 | created = models.DateTimeField('created') 6 | modified = models.DateTimeField('modified', auto_now=True) 7 | 8 | gender = models.IntegerField(choices=(('M', 1), ('F', 2))) 9 | name = models.CharField(max_length=32, unique=True) 10 | age = models.IntegerField() 11 | 12 | 13 | class Book(models.Model): 14 | created = models.DateTimeField('created') 15 | modified = models.DateTimeField('modified', auto_now=True) 16 | 17 | title = models.CharField(max_length=128) 18 | author = models.ForeignKey(Author, related_name='books', on_delete=models.CASCADE) 19 | -------------------------------------------------------------------------------- /project/testapp/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/testapp/users/__init__.py -------------------------------------------------------------------------------- /project/testapp/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppUsersConfig(AppConfig): 5 | name = "project.testapp.users" 6 | label = "users" 7 | -------------------------------------------------------------------------------- /project/testapp/users/insights.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django_insights.metrics import metrics 4 | from project.testapp.users.models import AppUser 5 | 6 | label = "Users" 7 | 8 | 9 | @metrics.counter( 10 | question="How many app users we have in our store?", 11 | desc="Number of app users", 12 | ) 13 | def count_app_users() -> int: 14 | return AppUser.objects.count() 15 | -------------------------------------------------------------------------------- /project/testapp/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-27 19:26 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ('auth', '0012_alter_user_first_name_max_length'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='AppUser', 17 | fields=[ 18 | ( 19 | 'id', 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name='ID', 25 | ), 26 | ), 27 | ('password', models.CharField(max_length=128, verbose_name='password')), 28 | ( 29 | 'is_superuser', 30 | models.BooleanField( 31 | default=False, 32 | help_text='Designates that this user has all permissions without explicitly assigning them.', 33 | verbose_name='superuser status', 34 | ), 35 | ), 36 | ('created', models.DateTimeField(verbose_name='created')), 37 | ( 38 | 'modified', 39 | models.DateTimeField(auto_now=True, verbose_name='modified'), 40 | ), 41 | ( 42 | 'email', 43 | models.EmailField( 44 | blank=True, default='', max_length=254, unique=True 45 | ), 46 | ), 47 | ('last_login', models.DateTimeField(blank=True, null=True)), 48 | ( 49 | 'date_joined', 50 | models.DateTimeField(default=django.utils.timezone.now), 51 | ), 52 | ( 53 | 'groups', 54 | models.ManyToManyField( 55 | blank=True, 56 | help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', 57 | related_name='user_set', 58 | related_query_name='user', 59 | to='auth.group', 60 | verbose_name='groups', 61 | ), 62 | ), 63 | ( 64 | 'user_permissions', 65 | models.ManyToManyField( 66 | blank=True, 67 | help_text='Specific permissions for this user.', 68 | related_name='user_set', 69 | related_query_name='user', 70 | to='auth.permission', 71 | verbose_name='user permissions', 72 | ), 73 | ), 74 | ], 75 | options={ 76 | 'abstract': False, 77 | }, 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /project/testapp/users/migrations/0002_alter_appuser_created.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-03-27 19:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('users', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='appuser', 14 | name='created', 15 | field=models.DateTimeField(auto_now_add=True, verbose_name='created'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /project/testapp/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/project/testapp/users/migrations/__init__.py -------------------------------------------------------------------------------- /project/testapp/users/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | 6 | class AppUser(AbstractBaseUser, PermissionsMixin): 7 | created = models.DateTimeField('created', auto_now_add=True) 8 | modified = models.DateTimeField('modified', auto_now=True) 9 | 10 | email = models.EmailField(blank=True, default="", unique=True) 11 | last_login = models.DateTimeField(blank=True, null=True) 12 | date_joined = models.DateTimeField(default=timezone.now) 13 | 14 | USERNAME_FIELD = "email" 15 | EMAIL_FIELD = "email" 16 | REQUIRED_FIELDS = [] 17 | -------------------------------------------------------------------------------- /project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import include, path 4 | 5 | urlpatterns = ( 6 | [ 7 | path( 8 | '', 9 | include('django_insights.urls', namespace='insights'), 10 | ), 11 | ] 12 | + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 13 | + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 14 | ) 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [distutils] 6 | index-servers = ["pypi"] 7 | 8 | [pypi] 9 | repository = "https://upload.pypi.org/legacy/" 10 | username = "terminalkitten" 11 | 12 | [project] 13 | name = "django-insights" 14 | authors = [{ name = "DK", email = "dk@terminalkitten.com" }] 15 | readme = "README.md" 16 | license = { file = "LICENSE" } 17 | classifiers = [ 18 | "Development Status :: 3 - Alpha", 19 | "Environment :: Web Environment", 20 | "Framework :: Django", 21 | "Framework :: Django :: 3.2", 22 | "Framework :: Django :: 4.0", 23 | "Framework :: Django :: 4.1", 24 | "Framework :: Django :: 4.2", 25 | "Framework :: Matplotlib", 26 | "Intended Audience :: Developers", 27 | "Operating System :: OS Independent", 28 | "License :: OSI Approved :: MIT License", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | ] 35 | dynamic = ["version", "description"] 36 | dependencies = [ 37 | "django", 38 | "matplotlib", 39 | "tqdm", 40 | "weasyprint>=53", 41 | "django-weasyprint>=2.3", 42 | ] 43 | 44 | [project.optional-dependencies] 45 | dev = [ 46 | "black", 47 | "ipython", 48 | "flit", 49 | "flake8", 50 | "pytest", 51 | "pytest-django", 52 | "mypy", 53 | "isort", 54 | "wheel", 55 | "faker", 56 | ] 57 | doc = ["pdoc"] 58 | 59 | [project.scripts] 60 | insights = "django_insights.cli:cli" 61 | 62 | [project.urls] 63 | Home = "https://github.com/terminalkitten/django-insights" 64 | 65 | [tool.django-stubs] 66 | django_settings_module = "project.settings" 67 | 68 | [tool.black] 69 | line-length = 88 70 | skip-string-normalization = true 71 | target-version = ['py39', 'py310', 'py311'] 72 | 73 | [tool.isort] 74 | profile = "black" 75 | line_length = 88 76 | 77 | [tool.pytest.ini_options] 78 | pythonpath = ["."] 79 | filterwarnings = ["error", "ignore::UserWarning"] 80 | addopts = """\ 81 | --strict-config 82 | --strict-markers 83 | --ds=project.settings.test 84 | """ 85 | django_find_project = false 86 | -------------------------------------------------------------------------------- /requirements/compile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import os 5 | import subprocess 6 | import sys 7 | from pathlib import Path 8 | 9 | if __name__ == "__main__": 10 | os.chdir(Path(__file__).parent) 11 | os.environ["CUSTOM_COMPILE_COMMAND"] = "requirements/compile.py" 12 | os.environ["PIP_REQUIRE_VIRTUALENV"] = "0" 13 | common_args = [ 14 | "-m", 15 | "piptools", 16 | "compile", 17 | "--generate-hashes", 18 | "--allow-unsafe", 19 | ] + sys.argv[1:] 20 | subprocess.run( 21 | [ 22 | "python3.8", 23 | *common_args, 24 | "-P", 25 | "Django>=3.2a1,<3.3", 26 | "-o", 27 | "py38-django32.txt", 28 | ], 29 | check=True, 30 | capture_output=True, 31 | ) 32 | subprocess.run( 33 | [ 34 | "python3.8", 35 | *common_args, 36 | "-P", 37 | "Django>=4.0a1,<4.1", 38 | "-o", 39 | "py38-django40.txt", 40 | ], 41 | check=True, 42 | capture_output=True, 43 | ) 44 | subprocess.run( 45 | [ 46 | "python3.8", 47 | *common_args, 48 | "-P", 49 | "Django>=4.1a1,<4.2", 50 | "-o", 51 | "py38-django41.txt", 52 | ], 53 | check=True, 54 | capture_output=True, 55 | ) 56 | subprocess.run( 57 | [ 58 | "python3.8", 59 | *common_args, 60 | "-P", 61 | "Django>=4.2a1,<5.0", 62 | "-o", 63 | "py38-django42.txt", 64 | ], 65 | check=True, 66 | capture_output=True, 67 | ) 68 | subprocess.run( 69 | [ 70 | "python3.9", 71 | *common_args, 72 | "-P", 73 | "Django>=3.2a1,<3.3", 74 | "-o", 75 | "py39-django32.txt", 76 | ], 77 | check=True, 78 | capture_output=True, 79 | ) 80 | subprocess.run( 81 | [ 82 | "python3.9", 83 | *common_args, 84 | "-P", 85 | "Django>=4.0a1,<4.1", 86 | "-o", 87 | "py39-django40.txt", 88 | ], 89 | check=True, 90 | capture_output=True, 91 | ) 92 | subprocess.run( 93 | [ 94 | "python3.9", 95 | *common_args, 96 | "-P", 97 | "Django>=4.1a1,<4.2", 98 | "-o", 99 | "py39-django41.txt", 100 | ], 101 | check=True, 102 | capture_output=True, 103 | ) 104 | subprocess.run( 105 | [ 106 | "python3.9", 107 | *common_args, 108 | "-P", 109 | "Django>=4.2a1,<5.0", 110 | "-o", 111 | "py39-django42.txt", 112 | ], 113 | check=True, 114 | capture_output=True, 115 | ) 116 | subprocess.run( 117 | [ 118 | "python3.10", 119 | *common_args, 120 | "-P", 121 | "Django>=3.2a1,<3.3", 122 | "-o", 123 | "py310-django32.txt", 124 | ], 125 | check=True, 126 | capture_output=True, 127 | ) 128 | subprocess.run( 129 | [ 130 | "python3.10", 131 | *common_args, 132 | "-P", 133 | "Django>=4.0a1,<4.1", 134 | "-o", 135 | "py310-django40.txt", 136 | ], 137 | check=True, 138 | capture_output=True, 139 | ) 140 | subprocess.run( 141 | [ 142 | "python3.10", 143 | *common_args, 144 | "-P", 145 | "Django>=4.1a1,<4.2", 146 | "-o", 147 | "py310-django41.txt", 148 | ], 149 | check=True, 150 | capture_output=True, 151 | ) 152 | subprocess.run( 153 | [ 154 | "python3.10", 155 | *common_args, 156 | "-P", 157 | "Django>=4.2a1,<5.0", 158 | "-o", 159 | "py310-django42.txt", 160 | ], 161 | check=True, 162 | capture_output=True, 163 | ) 164 | subprocess.run( 165 | [ 166 | "python3.11", 167 | *common_args, 168 | "-P", 169 | "Django>=4.1a1,<4.2", 170 | "-o", 171 | "py311-django41.txt", 172 | ], 173 | check=True, 174 | capture_output=True, 175 | ) 176 | subprocess.run( 177 | [ 178 | "python3.11", 179 | *common_args, 180 | "-P", 181 | "Django>=4.2a1,<5.0", 182 | "-o", 183 | "py311-django42.txt", 184 | ], 185 | check=True, 186 | capture_output=True, 187 | ) 188 | -------------------------------------------------------------------------------- /requirements/py311-django41.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # requirements/compile.py 6 | # 7 | asgiref==3.6.0 \ 8 | --hash=sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac \ 9 | --hash=sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506 10 | # via django 11 | attrs==22.2.0 \ 12 | --hash=sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836 \ 13 | --hash=sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99 14 | # via pytest 15 | contourpy==1.0.7 \ 16 | --hash=sha256:031154ed61f7328ad7f97662e48660a150ef84ee1bc8876b6472af88bf5a9b98 \ 17 | --hash=sha256:0f9d350b639db6c2c233d92c7f213d94d2e444d8e8fc5ca44c9706cf72193772 \ 18 | --hash=sha256:130230b7e49825c98edf0b428b7aa1125503d91732735ef897786fe5452b1ec2 \ 19 | --hash=sha256:152fd8f730c31fd67fe0ffebe1df38ab6a669403da93df218801a893645c6ccc \ 20 | --hash=sha256:1c71fdd8f1c0f84ffd58fca37d00ca4ebaa9e502fb49825484da075ac0b0b803 \ 21 | --hash=sha256:24847601071f740837aefb730e01bd169fbcaa610209779a78db7ebb6e6a7051 \ 22 | --hash=sha256:2e9ebb4425fc1b658e13bace354c48a933b842d53c458f02c86f371cecbedecc \ 23 | --hash=sha256:30676ca45084ee61e9c3da589042c24a57592e375d4b138bd84d8709893a1ba4 \ 24 | --hash=sha256:31a55dccc8426e71817e3fe09b37d6d48ae40aae4ecbc8c7ad59d6893569c436 \ 25 | --hash=sha256:366a0cf0fc079af5204801786ad7a1c007714ee3909e364dbac1729f5b0849e5 \ 26 | --hash=sha256:38e2e577f0f092b8e6774459317c05a69935a1755ecfb621c0a98f0e3c09c9a5 \ 27 | --hash=sha256:3c184ad2433635f216645fdf0493011a4667e8d46b34082f5a3de702b6ec42e3 \ 28 | --hash=sha256:3caea6365b13119626ee996711ab63e0c9d7496f65641f4459c60a009a1f3e80 \ 29 | --hash=sha256:3e927b3868bd1e12acee7cc8f3747d815b4ab3e445a28d2e5373a7f4a6e76ba1 \ 30 | --hash=sha256:4ee3ee247f795a69e53cd91d927146fb16c4e803c7ac86c84104940c7d2cabf0 \ 31 | --hash=sha256:54d43960d809c4c12508a60b66cb936e7ed57d51fb5e30b513934a4a23874fae \ 32 | --hash=sha256:57119b0116e3f408acbdccf9eb6ef19d7fe7baf0d1e9aaa5381489bc1aa56556 \ 33 | --hash=sha256:58569c491e7f7e874f11519ef46737cea1d6eda1b514e4eb5ac7dab6aa864d02 \ 34 | --hash=sha256:5a011cf354107b47c58ea932d13b04d93c6d1d69b8b6dce885e642531f847566 \ 35 | --hash=sha256:5caeacc68642e5f19d707471890f037a13007feba8427eb7f2a60811a1fc1350 \ 36 | --hash=sha256:5dd34c1ae752515318224cba7fc62b53130c45ac6a1040c8b7c1a223c46e8967 \ 37 | --hash=sha256:60835badb5ed5f4e194a6f21c09283dd6e007664a86101431bf870d9e86266c4 \ 38 | --hash=sha256:62398c80ef57589bdbe1eb8537127321c1abcfdf8c5f14f479dbbe27d0322e66 \ 39 | --hash=sha256:6381fa66866b0ea35e15d197fc06ac3840a9b2643a6475c8fff267db8b9f1e69 \ 40 | --hash=sha256:64757f6460fc55d7e16ed4f1de193f362104285c667c112b50a804d482777edd \ 41 | --hash=sha256:69f8ff4db108815addd900a74df665e135dbbd6547a8a69333a68e1f6e368ac2 \ 42 | --hash=sha256:6c180d89a28787e4b73b07e9b0e2dac7741261dbdca95f2b489c4f8f887dd810 \ 43 | --hash=sha256:71b0bf0c30d432278793d2141362ac853859e87de0a7dee24a1cea35231f0d50 \ 44 | --hash=sha256:769eef00437edf115e24d87f8926955f00f7704bede656ce605097584f9966dc \ 45 | --hash=sha256:7f6979d20ee5693a1057ab53e043adffa1e7418d734c1532e2d9e915b08d8ec2 \ 46 | --hash=sha256:87f4d8941a9564cda3f7fa6a6cd9b32ec575830780677932abdec7bcb61717b0 \ 47 | --hash=sha256:89ba9bb365446a22411f0673abf6ee1fea3b2cf47b37533b970904880ceb72f3 \ 48 | --hash=sha256:8acf74b5d383414401926c1598ed77825cd530ac7b463ebc2e4f46638f56cce6 \ 49 | --hash=sha256:9056c5310eb1daa33fc234ef39ebfb8c8e2533f088bbf0bc7350f70a29bde1ac \ 50 | --hash=sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d \ 51 | --hash=sha256:9e20e5a1908e18aaa60d9077a6d8753090e3f85ca25da6e25d30dc0a9e84c2c6 \ 52 | --hash=sha256:a1e97b86f73715e8670ef45292d7cc033548266f07d54e2183ecb3c87598888f \ 53 | --hash=sha256:a877ada905f7d69b2a31796c4b66e31a8068b37aa9b78832d41c82fc3e056ddd \ 54 | --hash=sha256:a9d7587d2fdc820cc9177139b56795c39fb8560f540bba9ceea215f1f66e1566 \ 55 | --hash=sha256:abf298af1e7ad44eeb93501e40eb5a67abbf93b5d90e468d01fc0c4451971afa \ 56 | --hash=sha256:ae90d5a8590e5310c32a7630b4b8618cef7563cebf649011da80874d0aa8f414 \ 57 | --hash=sha256:b6d0f9e1d39dbfb3977f9dd79f156c86eb03e57a7face96f199e02b18e58d32a \ 58 | --hash=sha256:b8d587cc39057d0afd4166083d289bdeff221ac6d3ee5046aef2d480dc4b503c \ 59 | --hash=sha256:c5210e5d5117e9aec8c47d9156d1d3835570dd909a899171b9535cb4a3f32693 \ 60 | --hash=sha256:cc331c13902d0f50845099434cd936d49d7a2ca76cb654b39691974cb1e4812d \ 61 | --hash=sha256:ce41676b3d0dd16dbcfabcc1dc46090aaf4688fd6e819ef343dbda5a57ef0161 \ 62 | --hash=sha256:d8165a088d31798b59e91117d1f5fc3df8168d8b48c4acc10fc0df0d0bdbcc5e \ 63 | --hash=sha256:e7281244c99fd7c6f27c1c6bfafba878517b0b62925a09b586d88ce750a016d2 \ 64 | --hash=sha256:e96a08b62bb8de960d3a6afbc5ed8421bf1a2d9c85cc4ea73f4bc81b4910500f \ 65 | --hash=sha256:ed33433fc3820263a6368e532f19ddb4c5990855e4886088ad84fd7c4e561c71 \ 66 | --hash=sha256:efb8f6d08ca7998cf59eaf50c9d60717f29a1a0a09caa46460d33b2924839dbd \ 67 | --hash=sha256:efe99298ba37e37787f6a2ea868265465410822f7bea163edcc1bd3903354ea9 \ 68 | --hash=sha256:f99e9486bf1bb979d95d5cffed40689cb595abb2b841f2991fc894b3452290e8 \ 69 | --hash=sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab \ 70 | --hash=sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad 71 | # via matplotlib 72 | cycler==0.11.0 \ 73 | --hash=sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3 \ 74 | --hash=sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f 75 | # via matplotlib 76 | django==4.1.7 \ 77 | --hash=sha256:44f714b81c5f190d9d2ddad01a532fe502fa01c4cb8faf1d081f4264ed15dcd8 \ 78 | --hash=sha256:f2f431e75adc40039ace496ad3b9f17227022e8b11566f4b363da44c7e44761e 79 | # via -r requirements.in 80 | faker==17.6.0 \ 81 | --hash=sha256:51f37ff9df710159d6d736d0ba1c75e063430a8c806b91334d7794305b5a6114 \ 82 | --hash=sha256:5aaa16fa9cfde7d117eef70b6b293a705021e57158f3fa6b44ed1b70202d2065 83 | # via -r requirements.in 84 | fonttools==4.39.0 \ 85 | --hash=sha256:909c104558835eac27faeb56be5a4c32694192dca123d073bf746ce9254054af \ 86 | --hash=sha256:f5e764e1fd6ad54dfc201ff32af0ba111bcfbe0d05b24540af74c63db4ed6390 87 | # via matplotlib 88 | iniconfig==2.0.0 \ 89 | --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ 90 | --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 91 | # via pytest 92 | kiwisolver==1.4.4 \ 93 | --hash=sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b \ 94 | --hash=sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166 \ 95 | --hash=sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c \ 96 | --hash=sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c \ 97 | --hash=sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0 \ 98 | --hash=sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4 \ 99 | --hash=sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9 \ 100 | --hash=sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286 \ 101 | --hash=sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767 \ 102 | --hash=sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c \ 103 | --hash=sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6 \ 104 | --hash=sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b \ 105 | --hash=sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004 \ 106 | --hash=sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf \ 107 | --hash=sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494 \ 108 | --hash=sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac \ 109 | --hash=sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626 \ 110 | --hash=sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766 \ 111 | --hash=sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514 \ 112 | --hash=sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6 \ 113 | --hash=sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f \ 114 | --hash=sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d \ 115 | --hash=sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191 \ 116 | --hash=sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d \ 117 | --hash=sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51 \ 118 | --hash=sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f \ 119 | --hash=sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8 \ 120 | --hash=sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454 \ 121 | --hash=sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb \ 122 | --hash=sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da \ 123 | --hash=sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8 \ 124 | --hash=sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de \ 125 | --hash=sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a \ 126 | --hash=sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9 \ 127 | --hash=sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008 \ 128 | --hash=sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3 \ 129 | --hash=sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32 \ 130 | --hash=sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938 \ 131 | --hash=sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1 \ 132 | --hash=sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9 \ 133 | --hash=sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d \ 134 | --hash=sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824 \ 135 | --hash=sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b \ 136 | --hash=sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd \ 137 | --hash=sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2 \ 138 | --hash=sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5 \ 139 | --hash=sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69 \ 140 | --hash=sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3 \ 141 | --hash=sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae \ 142 | --hash=sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597 \ 143 | --hash=sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e \ 144 | --hash=sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955 \ 145 | --hash=sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca \ 146 | --hash=sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a \ 147 | --hash=sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea \ 148 | --hash=sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede \ 149 | --hash=sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4 \ 150 | --hash=sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6 \ 151 | --hash=sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686 \ 152 | --hash=sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408 \ 153 | --hash=sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871 \ 154 | --hash=sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29 \ 155 | --hash=sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750 \ 156 | --hash=sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897 \ 157 | --hash=sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0 \ 158 | --hash=sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2 \ 159 | --hash=sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09 \ 160 | --hash=sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c 161 | # via matplotlib 162 | matplotlib==3.7.1 \ 163 | --hash=sha256:08308bae9e91aca1ec6fd6dda66237eef9f6294ddb17f0d0b3c863169bf82353 \ 164 | --hash=sha256:14645aad967684e92fc349493fa10c08a6da514b3d03a5931a1bac26e6792bd1 \ 165 | --hash=sha256:21e9cff1a58d42e74d01153360de92b326708fb205250150018a52c70f43c290 \ 166 | --hash=sha256:28506a03bd7f3fe59cd3cd4ceb2a8d8a2b1db41afede01f66c42561b9be7b4b7 \ 167 | --hash=sha256:2bf092f9210e105f414a043b92af583c98f50050559616930d884387d0772aba \ 168 | --hash=sha256:3032884084f541163f295db8a6536e0abb0db464008fadca6c98aaf84ccf4717 \ 169 | --hash=sha256:3a2cb34336110e0ed8bb4f650e817eed61fa064acbefeb3591f1b33e3a84fd96 \ 170 | --hash=sha256:3ba2af245e36990facf67fde840a760128ddd71210b2ab6406e640188d69d136 \ 171 | --hash=sha256:3d7bc90727351fb841e4d8ae620d2d86d8ed92b50473cd2b42ce9186104ecbba \ 172 | --hash=sha256:438196cdf5dc8d39b50a45cb6e3f6274edbcf2254f85fa9b895bf85851c3a613 \ 173 | --hash=sha256:46a561d23b91f30bccfd25429c3c706afe7d73a5cc64ef2dfaf2b2ac47c1a5dc \ 174 | --hash=sha256:4cf327e98ecf08fcbb82685acaf1939d3338548620ab8dfa02828706402c34de \ 175 | --hash=sha256:4f99e1b234c30c1e9714610eb0c6d2f11809c9c78c984a613ae539ea2ad2eb4b \ 176 | --hash=sha256:544764ba51900da4639c0f983b323d288f94f65f4024dc40ecb1542d74dc0500 \ 177 | --hash=sha256:56d94989191de3fcc4e002f93f7f1be5da476385dde410ddafbb70686acf00ea \ 178 | --hash=sha256:57bfb8c8ea253be947ccb2bc2d1bb3862c2bccc662ad1b4626e1f5e004557042 \ 179 | --hash=sha256:617f14ae9d53292ece33f45cba8503494ee199a75b44de7717964f70637a36aa \ 180 | --hash=sha256:6eb88d87cb2c49af00d3bbc33a003f89fd9f78d318848da029383bfc08ecfbfb \ 181 | --hash=sha256:75d4725d70b7c03e082bbb8a34639ede17f333d7247f56caceb3801cb6ff703d \ 182 | --hash=sha256:770a205966d641627fd5cf9d3cb4b6280a716522cd36b8b284a8eb1581310f61 \ 183 | --hash=sha256:7b73305f25eab4541bd7ee0b96d87e53ae9c9f1823be5659b806cd85786fe882 \ 184 | --hash=sha256:7c9a4b2da6fac77bcc41b1ea95fadb314e92508bf5493ceff058e727e7ecf5b0 \ 185 | --hash=sha256:81a6b377ea444336538638d31fdb39af6be1a043ca5e343fe18d0f17e098770b \ 186 | --hash=sha256:83111e6388dec67822e2534e13b243cc644c7494a4bb60584edbff91585a83c6 \ 187 | --hash=sha256:8704726d33e9aa8a6d5215044b8d00804561971163563e6e6591f9dcf64340cc \ 188 | --hash=sha256:89768d84187f31717349c6bfadc0e0d8c321e8eb34522acec8a67b1236a66332 \ 189 | --hash=sha256:8bf26ade3ff0f27668989d98c8435ce9327d24cffb7f07d24ef609e33d582439 \ 190 | --hash=sha256:8c587963b85ce41e0a8af53b9b2de8dddbf5ece4c34553f7bd9d066148dc719c \ 191 | --hash=sha256:95cbc13c1fc6844ab8812a525bbc237fa1470863ff3dace7352e910519e194b1 \ 192 | --hash=sha256:97cc368a7268141afb5690760921765ed34867ffb9655dd325ed207af85c7529 \ 193 | --hash=sha256:a867bf73a7eb808ef2afbca03bcdb785dae09595fbe550e1bab0cd023eba3de0 \ 194 | --hash=sha256:b867e2f952ed592237a1828f027d332d8ee219ad722345b79a001f49df0936eb \ 195 | --hash=sha256:c0bd19c72ae53e6ab979f0ac6a3fafceb02d2ecafa023c5cca47acd934d10be7 \ 196 | --hash=sha256:ce463ce590f3825b52e9fe5c19a3c6a69fd7675a39d589e8b5fbe772272b3a24 \ 197 | --hash=sha256:cf0e4f727534b7b1457898c4f4ae838af1ef87c359b76dcd5330fa31893a3ac7 \ 198 | --hash=sha256:def58098f96a05f90af7e92fd127d21a287068202aa43b2a93476170ebd99e87 \ 199 | --hash=sha256:e99bc9e65901bb9a7ce5e7bb24af03675cbd7c70b30ac670aa263240635999a4 \ 200 | --hash=sha256:eb7d248c34a341cd4c31a06fd34d64306624c8cd8d0def7abb08792a5abfd556 \ 201 | --hash=sha256:f67bfdb83a8232cb7a92b869f9355d677bce24485c460b19d01970b64b2ed476 \ 202 | --hash=sha256:f883a22a56a84dba3b588696a2b8a1ab0d2c3d41be53264115c71b0a942d8fdb \ 203 | --hash=sha256:fbdeeb58c0cf0595efe89c05c224e0a502d1aa6a8696e68a73c3efc6bc354304 204 | # via -r requirements.in 205 | numpy==1.24.2 \ 206 | --hash=sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22 \ 207 | --hash=sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f \ 208 | --hash=sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9 \ 209 | --hash=sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96 \ 210 | --hash=sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0 \ 211 | --hash=sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a \ 212 | --hash=sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281 \ 213 | --hash=sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04 \ 214 | --hash=sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468 \ 215 | --hash=sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253 \ 216 | --hash=sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756 \ 217 | --hash=sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a \ 218 | --hash=sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb \ 219 | --hash=sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d \ 220 | --hash=sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0 \ 221 | --hash=sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910 \ 222 | --hash=sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978 \ 223 | --hash=sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5 \ 224 | --hash=sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f \ 225 | --hash=sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a \ 226 | --hash=sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5 \ 227 | --hash=sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2 \ 228 | --hash=sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d \ 229 | --hash=sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95 \ 230 | --hash=sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5 \ 231 | --hash=sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d \ 232 | --hash=sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780 \ 233 | --hash=sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa 234 | # via 235 | # contourpy 236 | # matplotlib 237 | packaging==23.0 \ 238 | --hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \ 239 | --hash=sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97 240 | # via 241 | # matplotlib 242 | # pytest 243 | pillow==9.4.0 \ 244 | --hash=sha256:013016af6b3a12a2f40b704677f8b51f72cb007dac785a9933d5c86a72a7fe33 \ 245 | --hash=sha256:0845adc64fe9886db00f5ab68c4a8cd933ab749a87747555cec1c95acea64b0b \ 246 | --hash=sha256:0884ba7b515163a1a05440a138adeb722b8a6ae2c2b33aea93ea3118dd3a899e \ 247 | --hash=sha256:09b89ddc95c248ee788328528e6a2996e09eaccddeeb82a5356e92645733be35 \ 248 | --hash=sha256:0dd4c681b82214b36273c18ca7ee87065a50e013112eea7d78c7a1b89a739153 \ 249 | --hash=sha256:0e51f608da093e5d9038c592b5b575cadc12fd748af1479b5e858045fff955a9 \ 250 | --hash=sha256:0f3269304c1a7ce82f1759c12ce731ef9b6e95b6df829dccd9fe42912cc48569 \ 251 | --hash=sha256:16a8df99701f9095bea8a6c4b3197da105df6f74e6176c5b410bc2df2fd29a57 \ 252 | --hash=sha256:19005a8e58b7c1796bc0167862b1f54a64d3b44ee5d48152b06bb861458bc0f8 \ 253 | --hash=sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1 \ 254 | --hash=sha256:28676836c7796805914b76b1837a40f76827ee0d5398f72f7dcc634bae7c6264 \ 255 | --hash=sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157 \ 256 | --hash=sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9 \ 257 | --hash=sha256:3fa1284762aacca6dc97474ee9c16f83990b8eeb6697f2ba17140d54b453e133 \ 258 | --hash=sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9 \ 259 | --hash=sha256:451f10ef963918e65b8869e17d67db5e2f4ab40e716ee6ce7129b0cde2876eab \ 260 | --hash=sha256:46c259e87199041583658457372a183636ae8cd56dbf3f0755e0f376a7f9d0e6 \ 261 | --hash=sha256:46f39cab8bbf4a384ba7cb0bc8bae7b7062b6a11cfac1ca4bc144dea90d4a9f5 \ 262 | --hash=sha256:519e14e2c49fcf7616d6d2cfc5c70adae95682ae20f0395e9280db85e8d6c4df \ 263 | --hash=sha256:53dcb50fbdc3fb2c55431a9b30caeb2f7027fcd2aeb501459464f0214200a503 \ 264 | --hash=sha256:54614444887e0d3043557d9dbc697dbb16cfb5a35d672b7a0fcc1ed0cf1c600b \ 265 | --hash=sha256:575d8912dca808edd9acd6f7795199332696d3469665ef26163cd090fa1f8bfa \ 266 | --hash=sha256:5dd5a9c3091a0f414a963d427f920368e2b6a4c2f7527fdd82cde8ef0bc7a327 \ 267 | --hash=sha256:5f532a2ad4d174eb73494e7397988e22bf427f91acc8e6ebf5bb10597b49c493 \ 268 | --hash=sha256:60e7da3a3ad1812c128750fc1bc14a7ceeb8d29f77e0a2356a8fb2aa8925287d \ 269 | --hash=sha256:653d7fb2df65efefbcbf81ef5fe5e5be931f1ee4332c2893ca638c9b11a409c4 \ 270 | --hash=sha256:6663977496d616b618b6cfa43ec86e479ee62b942e1da76a2c3daa1c75933ef4 \ 271 | --hash=sha256:6abfb51a82e919e3933eb137e17c4ae9c0475a25508ea88993bb59faf82f3b35 \ 272 | --hash=sha256:6c6b1389ed66cdd174d040105123a5a1bc91d0aa7059c7261d20e583b6d8cbd2 \ 273 | --hash=sha256:6d9dfb9959a3b0039ee06c1a1a90dc23bac3b430842dcb97908ddde05870601c \ 274 | --hash=sha256:765cb54c0b8724a7c12c55146ae4647e0274a839fb6de7bcba841e04298e1011 \ 275 | --hash=sha256:7a21222644ab69ddd9967cfe6f2bb420b460dae4289c9d40ff9a4896e7c35c9a \ 276 | --hash=sha256:7ac7594397698f77bce84382929747130765f66406dc2cd8b4ab4da68ade4c6e \ 277 | --hash=sha256:7cfc287da09f9d2a7ec146ee4d72d6ea1342e770d975e49a8621bf54eaa8f30f \ 278 | --hash=sha256:83125753a60cfc8c412de5896d10a0a405e0bd88d0470ad82e0869ddf0cb3848 \ 279 | --hash=sha256:847b114580c5cc9ebaf216dd8c8dbc6b00a3b7ab0131e173d7120e6deade1f57 \ 280 | --hash=sha256:87708d78a14d56a990fbf4f9cb350b7d89ee8988705e58e39bdf4d82c149210f \ 281 | --hash=sha256:8a2b5874d17e72dfb80d917213abd55d7e1ed2479f38f001f264f7ce7bae757c \ 282 | --hash=sha256:8f127e7b028900421cad64f51f75c051b628db17fb00e099eb148761eed598c9 \ 283 | --hash=sha256:94cdff45173b1919350601f82d61365e792895e3c3a3443cf99819e6fbf717a5 \ 284 | --hash=sha256:99d92d148dd03fd19d16175b6d355cc1b01faf80dae93c6c3eb4163709edc0a9 \ 285 | --hash=sha256:9a3049a10261d7f2b6514d35bbb7a4dfc3ece4c4de14ef5876c4b7a23a0e566d \ 286 | --hash=sha256:9d9a62576b68cd90f7075876f4e8444487db5eeea0e4df3ba298ee38a8d067b0 \ 287 | --hash=sha256:9e5f94742033898bfe84c93c831a6f552bb629448d4072dd312306bab3bd96f1 \ 288 | --hash=sha256:a1c2d7780448eb93fbcc3789bf3916aa5720d942e37945f4056680317f1cd23e \ 289 | --hash=sha256:a2e0f87144fcbbe54297cae708c5e7f9da21a4646523456b00cc956bd4c65815 \ 290 | --hash=sha256:a4dfdae195335abb4e89cc9762b2edc524f3c6e80d647a9a81bf81e17e3fb6f0 \ 291 | --hash=sha256:a96e6e23f2b79433390273eaf8cc94fec9c6370842e577ab10dabdcc7ea0a66b \ 292 | --hash=sha256:aabdab8ec1e7ca7f1434d042bf8b1e92056245fb179790dc97ed040361f16bfd \ 293 | --hash=sha256:b222090c455d6d1a64e6b7bb5f4035c4dff479e22455c9eaa1bdd4c75b52c80c \ 294 | --hash=sha256:b52ff4f4e002f828ea6483faf4c4e8deea8d743cf801b74910243c58acc6eda3 \ 295 | --hash=sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab \ 296 | --hash=sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858 \ 297 | --hash=sha256:b9b752ab91e78234941e44abdecc07f1f0d8f51fb62941d32995b8161f68cfe5 \ 298 | --hash=sha256:ba6612b6548220ff5e9df85261bddc811a057b0b465a1226b39bfb8550616aee \ 299 | --hash=sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343 \ 300 | --hash=sha256:c3c4ed2ff6760e98d262e0cc9c9a7f7b8a9f61aa4d47c58835cdaf7b0b8811bb \ 301 | --hash=sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47 \ 302 | --hash=sha256:cb362e3b0976dc994857391b776ddaa8c13c28a16f80ac6522c23d5257156bed \ 303 | --hash=sha256:d197df5489004db87d90b918033edbeee0bd6df3848a204bca3ff0a903bef837 \ 304 | --hash=sha256:d3b56206244dc8711f7e8b7d6cad4663917cd5b2d950799425076681e8766286 \ 305 | --hash=sha256:d5b2f8a31bd43e0f18172d8ac82347c8f37ef3e0b414431157718aa234991b28 \ 306 | --hash=sha256:d7081c084ceb58278dd3cf81f836bc818978c0ccc770cbbb202125ddabec6628 \ 307 | --hash=sha256:db74f5562c09953b2c5f8ec4b7dfd3f5421f31811e97d1dbc0a7c93d6e3a24df \ 308 | --hash=sha256:df41112ccce5d47770a0c13651479fbcd8793f34232a2dd9faeccb75eb5d0d0d \ 309 | --hash=sha256:e1339790c083c5a4de48f688b4841f18df839eb3c9584a770cbd818b33e26d5d \ 310 | --hash=sha256:e621b0246192d3b9cb1dc62c78cfa4c6f6d2ddc0ec207d43c0dedecb914f152a \ 311 | --hash=sha256:e8c5cf126889a4de385c02a2c3d3aba4b00f70234bfddae82a5eaa3ee6d5e3e6 \ 312 | --hash=sha256:e9d7747847c53a16a729b6ee5e737cf170f7a16611c143d95aa60a109a59c336 \ 313 | --hash=sha256:eaef5d2de3c7e9b21f1e762f289d17b726c2239a42b11e25446abf82b26ac132 \ 314 | --hash=sha256:ed3e4b4e1e6de75fdc16d3259098de7c6571b1a6cc863b1a49e7d3d53e036070 \ 315 | --hash=sha256:ef21af928e807f10bf4141cad4746eee692a0dd3ff56cfb25fce076ec3cc8abe \ 316 | --hash=sha256:f09598b416ba39a8f489c124447b007fe865f786a89dbfa48bb5cf395693132a \ 317 | --hash=sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd \ 318 | --hash=sha256:f6e78171be3fb7941f9910ea15b4b14ec27725865a73c15277bc39f5ca4f8391 \ 319 | --hash=sha256:f715c32e774a60a337b2bb8ad9839b4abf75b267a0f18806f6f4f5f1688c4b5a \ 320 | --hash=sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12 321 | # via matplotlib 322 | pluggy==1.0.0 \ 323 | --hash=sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159 \ 324 | --hash=sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3 325 | # via pytest 326 | pyparsing==3.0.9 \ 327 | --hash=sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb \ 328 | --hash=sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc 329 | # via matplotlib 330 | pytest==7.2.1 \ 331 | --hash=sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5 \ 332 | --hash=sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42 333 | # via 334 | # -r requirements.in 335 | # pytest-django 336 | pytest-django==4.5.2 \ 337 | --hash=sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e \ 338 | --hash=sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2 339 | # via -r requirements.in 340 | python-dateutil==2.8.2 \ 341 | --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ 342 | --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 343 | # via 344 | # faker 345 | # matplotlib 346 | six==1.16.0 \ 347 | --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ 348 | --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 349 | # via python-dateutil 350 | sqlparse==0.4.3 \ 351 | --hash=sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34 \ 352 | --hash=sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268 353 | # via django 354 | tqdm==4.64.1 \ 355 | --hash=sha256:5f4f682a004951c1b450bc753c710e9280c5746ce6ffedee253ddbcbf54cf1e4 \ 356 | --hash=sha256:6fee160d6ffcd1b1c68c65f14c829c22832bc401726335ce92c52d395944a6a1 357 | # via -r requirements.in 358 | -------------------------------------------------------------------------------- /requirements/py311-django42.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # requirements/compile.py 6 | # 7 | asgiref==3.6.0 \ 8 | --hash=sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac \ 9 | --hash=sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506 10 | # via django 11 | attrs==22.2.0 \ 12 | --hash=sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836 \ 13 | --hash=sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99 14 | # via pytest 15 | contourpy==1.0.7 \ 16 | --hash=sha256:031154ed61f7328ad7f97662e48660a150ef84ee1bc8876b6472af88bf5a9b98 \ 17 | --hash=sha256:0f9d350b639db6c2c233d92c7f213d94d2e444d8e8fc5ca44c9706cf72193772 \ 18 | --hash=sha256:130230b7e49825c98edf0b428b7aa1125503d91732735ef897786fe5452b1ec2 \ 19 | --hash=sha256:152fd8f730c31fd67fe0ffebe1df38ab6a669403da93df218801a893645c6ccc \ 20 | --hash=sha256:1c71fdd8f1c0f84ffd58fca37d00ca4ebaa9e502fb49825484da075ac0b0b803 \ 21 | --hash=sha256:24847601071f740837aefb730e01bd169fbcaa610209779a78db7ebb6e6a7051 \ 22 | --hash=sha256:2e9ebb4425fc1b658e13bace354c48a933b842d53c458f02c86f371cecbedecc \ 23 | --hash=sha256:30676ca45084ee61e9c3da589042c24a57592e375d4b138bd84d8709893a1ba4 \ 24 | --hash=sha256:31a55dccc8426e71817e3fe09b37d6d48ae40aae4ecbc8c7ad59d6893569c436 \ 25 | --hash=sha256:366a0cf0fc079af5204801786ad7a1c007714ee3909e364dbac1729f5b0849e5 \ 26 | --hash=sha256:38e2e577f0f092b8e6774459317c05a69935a1755ecfb621c0a98f0e3c09c9a5 \ 27 | --hash=sha256:3c184ad2433635f216645fdf0493011a4667e8d46b34082f5a3de702b6ec42e3 \ 28 | --hash=sha256:3caea6365b13119626ee996711ab63e0c9d7496f65641f4459c60a009a1f3e80 \ 29 | --hash=sha256:3e927b3868bd1e12acee7cc8f3747d815b4ab3e445a28d2e5373a7f4a6e76ba1 \ 30 | --hash=sha256:4ee3ee247f795a69e53cd91d927146fb16c4e803c7ac86c84104940c7d2cabf0 \ 31 | --hash=sha256:54d43960d809c4c12508a60b66cb936e7ed57d51fb5e30b513934a4a23874fae \ 32 | --hash=sha256:57119b0116e3f408acbdccf9eb6ef19d7fe7baf0d1e9aaa5381489bc1aa56556 \ 33 | --hash=sha256:58569c491e7f7e874f11519ef46737cea1d6eda1b514e4eb5ac7dab6aa864d02 \ 34 | --hash=sha256:5a011cf354107b47c58ea932d13b04d93c6d1d69b8b6dce885e642531f847566 \ 35 | --hash=sha256:5caeacc68642e5f19d707471890f037a13007feba8427eb7f2a60811a1fc1350 \ 36 | --hash=sha256:5dd34c1ae752515318224cba7fc62b53130c45ac6a1040c8b7c1a223c46e8967 \ 37 | --hash=sha256:60835badb5ed5f4e194a6f21c09283dd6e007664a86101431bf870d9e86266c4 \ 38 | --hash=sha256:62398c80ef57589bdbe1eb8537127321c1abcfdf8c5f14f479dbbe27d0322e66 \ 39 | --hash=sha256:6381fa66866b0ea35e15d197fc06ac3840a9b2643a6475c8fff267db8b9f1e69 \ 40 | --hash=sha256:64757f6460fc55d7e16ed4f1de193f362104285c667c112b50a804d482777edd \ 41 | --hash=sha256:69f8ff4db108815addd900a74df665e135dbbd6547a8a69333a68e1f6e368ac2 \ 42 | --hash=sha256:6c180d89a28787e4b73b07e9b0e2dac7741261dbdca95f2b489c4f8f887dd810 \ 43 | --hash=sha256:71b0bf0c30d432278793d2141362ac853859e87de0a7dee24a1cea35231f0d50 \ 44 | --hash=sha256:769eef00437edf115e24d87f8926955f00f7704bede656ce605097584f9966dc \ 45 | --hash=sha256:7f6979d20ee5693a1057ab53e043adffa1e7418d734c1532e2d9e915b08d8ec2 \ 46 | --hash=sha256:87f4d8941a9564cda3f7fa6a6cd9b32ec575830780677932abdec7bcb61717b0 \ 47 | --hash=sha256:89ba9bb365446a22411f0673abf6ee1fea3b2cf47b37533b970904880ceb72f3 \ 48 | --hash=sha256:8acf74b5d383414401926c1598ed77825cd530ac7b463ebc2e4f46638f56cce6 \ 49 | --hash=sha256:9056c5310eb1daa33fc234ef39ebfb8c8e2533f088bbf0bc7350f70a29bde1ac \ 50 | --hash=sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d \ 51 | --hash=sha256:9e20e5a1908e18aaa60d9077a6d8753090e3f85ca25da6e25d30dc0a9e84c2c6 \ 52 | --hash=sha256:a1e97b86f73715e8670ef45292d7cc033548266f07d54e2183ecb3c87598888f \ 53 | --hash=sha256:a877ada905f7d69b2a31796c4b66e31a8068b37aa9b78832d41c82fc3e056ddd \ 54 | --hash=sha256:a9d7587d2fdc820cc9177139b56795c39fb8560f540bba9ceea215f1f66e1566 \ 55 | --hash=sha256:abf298af1e7ad44eeb93501e40eb5a67abbf93b5d90e468d01fc0c4451971afa \ 56 | --hash=sha256:ae90d5a8590e5310c32a7630b4b8618cef7563cebf649011da80874d0aa8f414 \ 57 | --hash=sha256:b6d0f9e1d39dbfb3977f9dd79f156c86eb03e57a7face96f199e02b18e58d32a \ 58 | --hash=sha256:b8d587cc39057d0afd4166083d289bdeff221ac6d3ee5046aef2d480dc4b503c \ 59 | --hash=sha256:c5210e5d5117e9aec8c47d9156d1d3835570dd909a899171b9535cb4a3f32693 \ 60 | --hash=sha256:cc331c13902d0f50845099434cd936d49d7a2ca76cb654b39691974cb1e4812d \ 61 | --hash=sha256:ce41676b3d0dd16dbcfabcc1dc46090aaf4688fd6e819ef343dbda5a57ef0161 \ 62 | --hash=sha256:d8165a088d31798b59e91117d1f5fc3df8168d8b48c4acc10fc0df0d0bdbcc5e \ 63 | --hash=sha256:e7281244c99fd7c6f27c1c6bfafba878517b0b62925a09b586d88ce750a016d2 \ 64 | --hash=sha256:e96a08b62bb8de960d3a6afbc5ed8421bf1a2d9c85cc4ea73f4bc81b4910500f \ 65 | --hash=sha256:ed33433fc3820263a6368e532f19ddb4c5990855e4886088ad84fd7c4e561c71 \ 66 | --hash=sha256:efb8f6d08ca7998cf59eaf50c9d60717f29a1a0a09caa46460d33b2924839dbd \ 67 | --hash=sha256:efe99298ba37e37787f6a2ea868265465410822f7bea163edcc1bd3903354ea9 \ 68 | --hash=sha256:f99e9486bf1bb979d95d5cffed40689cb595abb2b841f2991fc894b3452290e8 \ 69 | --hash=sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab \ 70 | --hash=sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad 71 | # via matplotlib 72 | cycler==0.11.0 \ 73 | --hash=sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3 \ 74 | --hash=sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f 75 | # via matplotlib 76 | django==4.2b1 \ 77 | --hash=sha256:33e3b3b80924dae3e6d4b5e697eaee724d5a35c1a430df44b1d72c802657992f \ 78 | --hash=sha256:9bf13063a882a9b0f7028c4cdc32ea36fe104491cd7720859117990933f9c589 79 | # via -r requirements.in 80 | faker==17.6.0 \ 81 | --hash=sha256:51f37ff9df710159d6d736d0ba1c75e063430a8c806b91334d7794305b5a6114 \ 82 | --hash=sha256:5aaa16fa9cfde7d117eef70b6b293a705021e57158f3fa6b44ed1b70202d2065 83 | # via -r requirements.in 84 | fonttools==4.39.0 \ 85 | --hash=sha256:909c104558835eac27faeb56be5a4c32694192dca123d073bf746ce9254054af \ 86 | --hash=sha256:f5e764e1fd6ad54dfc201ff32af0ba111bcfbe0d05b24540af74c63db4ed6390 87 | # via matplotlib 88 | iniconfig==2.0.0 \ 89 | --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ 90 | --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 91 | # via pytest 92 | kiwisolver==1.4.4 \ 93 | --hash=sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b \ 94 | --hash=sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166 \ 95 | --hash=sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c \ 96 | --hash=sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c \ 97 | --hash=sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0 \ 98 | --hash=sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4 \ 99 | --hash=sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9 \ 100 | --hash=sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286 \ 101 | --hash=sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767 \ 102 | --hash=sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c \ 103 | --hash=sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6 \ 104 | --hash=sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b \ 105 | --hash=sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004 \ 106 | --hash=sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf \ 107 | --hash=sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494 \ 108 | --hash=sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac \ 109 | --hash=sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626 \ 110 | --hash=sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766 \ 111 | --hash=sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514 \ 112 | --hash=sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6 \ 113 | --hash=sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f \ 114 | --hash=sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d \ 115 | --hash=sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191 \ 116 | --hash=sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d \ 117 | --hash=sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51 \ 118 | --hash=sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f \ 119 | --hash=sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8 \ 120 | --hash=sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454 \ 121 | --hash=sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb \ 122 | --hash=sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da \ 123 | --hash=sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8 \ 124 | --hash=sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de \ 125 | --hash=sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a \ 126 | --hash=sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9 \ 127 | --hash=sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008 \ 128 | --hash=sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3 \ 129 | --hash=sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32 \ 130 | --hash=sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938 \ 131 | --hash=sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1 \ 132 | --hash=sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9 \ 133 | --hash=sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d \ 134 | --hash=sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824 \ 135 | --hash=sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b \ 136 | --hash=sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd \ 137 | --hash=sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2 \ 138 | --hash=sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5 \ 139 | --hash=sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69 \ 140 | --hash=sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3 \ 141 | --hash=sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae \ 142 | --hash=sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597 \ 143 | --hash=sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e \ 144 | --hash=sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955 \ 145 | --hash=sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca \ 146 | --hash=sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a \ 147 | --hash=sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea \ 148 | --hash=sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede \ 149 | --hash=sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4 \ 150 | --hash=sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6 \ 151 | --hash=sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686 \ 152 | --hash=sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408 \ 153 | --hash=sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871 \ 154 | --hash=sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29 \ 155 | --hash=sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750 \ 156 | --hash=sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897 \ 157 | --hash=sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0 \ 158 | --hash=sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2 \ 159 | --hash=sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09 \ 160 | --hash=sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c 161 | # via matplotlib 162 | matplotlib==3.7.1 \ 163 | --hash=sha256:08308bae9e91aca1ec6fd6dda66237eef9f6294ddb17f0d0b3c863169bf82353 \ 164 | --hash=sha256:14645aad967684e92fc349493fa10c08a6da514b3d03a5931a1bac26e6792bd1 \ 165 | --hash=sha256:21e9cff1a58d42e74d01153360de92b326708fb205250150018a52c70f43c290 \ 166 | --hash=sha256:28506a03bd7f3fe59cd3cd4ceb2a8d8a2b1db41afede01f66c42561b9be7b4b7 \ 167 | --hash=sha256:2bf092f9210e105f414a043b92af583c98f50050559616930d884387d0772aba \ 168 | --hash=sha256:3032884084f541163f295db8a6536e0abb0db464008fadca6c98aaf84ccf4717 \ 169 | --hash=sha256:3a2cb34336110e0ed8bb4f650e817eed61fa064acbefeb3591f1b33e3a84fd96 \ 170 | --hash=sha256:3ba2af245e36990facf67fde840a760128ddd71210b2ab6406e640188d69d136 \ 171 | --hash=sha256:3d7bc90727351fb841e4d8ae620d2d86d8ed92b50473cd2b42ce9186104ecbba \ 172 | --hash=sha256:438196cdf5dc8d39b50a45cb6e3f6274edbcf2254f85fa9b895bf85851c3a613 \ 173 | --hash=sha256:46a561d23b91f30bccfd25429c3c706afe7d73a5cc64ef2dfaf2b2ac47c1a5dc \ 174 | --hash=sha256:4cf327e98ecf08fcbb82685acaf1939d3338548620ab8dfa02828706402c34de \ 175 | --hash=sha256:4f99e1b234c30c1e9714610eb0c6d2f11809c9c78c984a613ae539ea2ad2eb4b \ 176 | --hash=sha256:544764ba51900da4639c0f983b323d288f94f65f4024dc40ecb1542d74dc0500 \ 177 | --hash=sha256:56d94989191de3fcc4e002f93f7f1be5da476385dde410ddafbb70686acf00ea \ 178 | --hash=sha256:57bfb8c8ea253be947ccb2bc2d1bb3862c2bccc662ad1b4626e1f5e004557042 \ 179 | --hash=sha256:617f14ae9d53292ece33f45cba8503494ee199a75b44de7717964f70637a36aa \ 180 | --hash=sha256:6eb88d87cb2c49af00d3bbc33a003f89fd9f78d318848da029383bfc08ecfbfb \ 181 | --hash=sha256:75d4725d70b7c03e082bbb8a34639ede17f333d7247f56caceb3801cb6ff703d \ 182 | --hash=sha256:770a205966d641627fd5cf9d3cb4b6280a716522cd36b8b284a8eb1581310f61 \ 183 | --hash=sha256:7b73305f25eab4541bd7ee0b96d87e53ae9c9f1823be5659b806cd85786fe882 \ 184 | --hash=sha256:7c9a4b2da6fac77bcc41b1ea95fadb314e92508bf5493ceff058e727e7ecf5b0 \ 185 | --hash=sha256:81a6b377ea444336538638d31fdb39af6be1a043ca5e343fe18d0f17e098770b \ 186 | --hash=sha256:83111e6388dec67822e2534e13b243cc644c7494a4bb60584edbff91585a83c6 \ 187 | --hash=sha256:8704726d33e9aa8a6d5215044b8d00804561971163563e6e6591f9dcf64340cc \ 188 | --hash=sha256:89768d84187f31717349c6bfadc0e0d8c321e8eb34522acec8a67b1236a66332 \ 189 | --hash=sha256:8bf26ade3ff0f27668989d98c8435ce9327d24cffb7f07d24ef609e33d582439 \ 190 | --hash=sha256:8c587963b85ce41e0a8af53b9b2de8dddbf5ece4c34553f7bd9d066148dc719c \ 191 | --hash=sha256:95cbc13c1fc6844ab8812a525bbc237fa1470863ff3dace7352e910519e194b1 \ 192 | --hash=sha256:97cc368a7268141afb5690760921765ed34867ffb9655dd325ed207af85c7529 \ 193 | --hash=sha256:a867bf73a7eb808ef2afbca03bcdb785dae09595fbe550e1bab0cd023eba3de0 \ 194 | --hash=sha256:b867e2f952ed592237a1828f027d332d8ee219ad722345b79a001f49df0936eb \ 195 | --hash=sha256:c0bd19c72ae53e6ab979f0ac6a3fafceb02d2ecafa023c5cca47acd934d10be7 \ 196 | --hash=sha256:ce463ce590f3825b52e9fe5c19a3c6a69fd7675a39d589e8b5fbe772272b3a24 \ 197 | --hash=sha256:cf0e4f727534b7b1457898c4f4ae838af1ef87c359b76dcd5330fa31893a3ac7 \ 198 | --hash=sha256:def58098f96a05f90af7e92fd127d21a287068202aa43b2a93476170ebd99e87 \ 199 | --hash=sha256:e99bc9e65901bb9a7ce5e7bb24af03675cbd7c70b30ac670aa263240635999a4 \ 200 | --hash=sha256:eb7d248c34a341cd4c31a06fd34d64306624c8cd8d0def7abb08792a5abfd556 \ 201 | --hash=sha256:f67bfdb83a8232cb7a92b869f9355d677bce24485c460b19d01970b64b2ed476 \ 202 | --hash=sha256:f883a22a56a84dba3b588696a2b8a1ab0d2c3d41be53264115c71b0a942d8fdb \ 203 | --hash=sha256:fbdeeb58c0cf0595efe89c05c224e0a502d1aa6a8696e68a73c3efc6bc354304 204 | # via -r requirements.in 205 | numpy==1.24.2 \ 206 | --hash=sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22 \ 207 | --hash=sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f \ 208 | --hash=sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9 \ 209 | --hash=sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96 \ 210 | --hash=sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0 \ 211 | --hash=sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a \ 212 | --hash=sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281 \ 213 | --hash=sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04 \ 214 | --hash=sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468 \ 215 | --hash=sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253 \ 216 | --hash=sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756 \ 217 | --hash=sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a \ 218 | --hash=sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb \ 219 | --hash=sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d \ 220 | --hash=sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0 \ 221 | --hash=sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910 \ 222 | --hash=sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978 \ 223 | --hash=sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5 \ 224 | --hash=sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f \ 225 | --hash=sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a \ 226 | --hash=sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5 \ 227 | --hash=sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2 \ 228 | --hash=sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d \ 229 | --hash=sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95 \ 230 | --hash=sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5 \ 231 | --hash=sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d \ 232 | --hash=sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780 \ 233 | --hash=sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa 234 | # via 235 | # contourpy 236 | # matplotlib 237 | packaging==23.0 \ 238 | --hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \ 239 | --hash=sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97 240 | # via 241 | # matplotlib 242 | # pytest 243 | pillow==9.4.0 \ 244 | --hash=sha256:013016af6b3a12a2f40b704677f8b51f72cb007dac785a9933d5c86a72a7fe33 \ 245 | --hash=sha256:0845adc64fe9886db00f5ab68c4a8cd933ab749a87747555cec1c95acea64b0b \ 246 | --hash=sha256:0884ba7b515163a1a05440a138adeb722b8a6ae2c2b33aea93ea3118dd3a899e \ 247 | --hash=sha256:09b89ddc95c248ee788328528e6a2996e09eaccddeeb82a5356e92645733be35 \ 248 | --hash=sha256:0dd4c681b82214b36273c18ca7ee87065a50e013112eea7d78c7a1b89a739153 \ 249 | --hash=sha256:0e51f608da093e5d9038c592b5b575cadc12fd748af1479b5e858045fff955a9 \ 250 | --hash=sha256:0f3269304c1a7ce82f1759c12ce731ef9b6e95b6df829dccd9fe42912cc48569 \ 251 | --hash=sha256:16a8df99701f9095bea8a6c4b3197da105df6f74e6176c5b410bc2df2fd29a57 \ 252 | --hash=sha256:19005a8e58b7c1796bc0167862b1f54a64d3b44ee5d48152b06bb861458bc0f8 \ 253 | --hash=sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1 \ 254 | --hash=sha256:28676836c7796805914b76b1837a40f76827ee0d5398f72f7dcc634bae7c6264 \ 255 | --hash=sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157 \ 256 | --hash=sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9 \ 257 | --hash=sha256:3fa1284762aacca6dc97474ee9c16f83990b8eeb6697f2ba17140d54b453e133 \ 258 | --hash=sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9 \ 259 | --hash=sha256:451f10ef963918e65b8869e17d67db5e2f4ab40e716ee6ce7129b0cde2876eab \ 260 | --hash=sha256:46c259e87199041583658457372a183636ae8cd56dbf3f0755e0f376a7f9d0e6 \ 261 | --hash=sha256:46f39cab8bbf4a384ba7cb0bc8bae7b7062b6a11cfac1ca4bc144dea90d4a9f5 \ 262 | --hash=sha256:519e14e2c49fcf7616d6d2cfc5c70adae95682ae20f0395e9280db85e8d6c4df \ 263 | --hash=sha256:53dcb50fbdc3fb2c55431a9b30caeb2f7027fcd2aeb501459464f0214200a503 \ 264 | --hash=sha256:54614444887e0d3043557d9dbc697dbb16cfb5a35d672b7a0fcc1ed0cf1c600b \ 265 | --hash=sha256:575d8912dca808edd9acd6f7795199332696d3469665ef26163cd090fa1f8bfa \ 266 | --hash=sha256:5dd5a9c3091a0f414a963d427f920368e2b6a4c2f7527fdd82cde8ef0bc7a327 \ 267 | --hash=sha256:5f532a2ad4d174eb73494e7397988e22bf427f91acc8e6ebf5bb10597b49c493 \ 268 | --hash=sha256:60e7da3a3ad1812c128750fc1bc14a7ceeb8d29f77e0a2356a8fb2aa8925287d \ 269 | --hash=sha256:653d7fb2df65efefbcbf81ef5fe5e5be931f1ee4332c2893ca638c9b11a409c4 \ 270 | --hash=sha256:6663977496d616b618b6cfa43ec86e479ee62b942e1da76a2c3daa1c75933ef4 \ 271 | --hash=sha256:6abfb51a82e919e3933eb137e17c4ae9c0475a25508ea88993bb59faf82f3b35 \ 272 | --hash=sha256:6c6b1389ed66cdd174d040105123a5a1bc91d0aa7059c7261d20e583b6d8cbd2 \ 273 | --hash=sha256:6d9dfb9959a3b0039ee06c1a1a90dc23bac3b430842dcb97908ddde05870601c \ 274 | --hash=sha256:765cb54c0b8724a7c12c55146ae4647e0274a839fb6de7bcba841e04298e1011 \ 275 | --hash=sha256:7a21222644ab69ddd9967cfe6f2bb420b460dae4289c9d40ff9a4896e7c35c9a \ 276 | --hash=sha256:7ac7594397698f77bce84382929747130765f66406dc2cd8b4ab4da68ade4c6e \ 277 | --hash=sha256:7cfc287da09f9d2a7ec146ee4d72d6ea1342e770d975e49a8621bf54eaa8f30f \ 278 | --hash=sha256:83125753a60cfc8c412de5896d10a0a405e0bd88d0470ad82e0869ddf0cb3848 \ 279 | --hash=sha256:847b114580c5cc9ebaf216dd8c8dbc6b00a3b7ab0131e173d7120e6deade1f57 \ 280 | --hash=sha256:87708d78a14d56a990fbf4f9cb350b7d89ee8988705e58e39bdf4d82c149210f \ 281 | --hash=sha256:8a2b5874d17e72dfb80d917213abd55d7e1ed2479f38f001f264f7ce7bae757c \ 282 | --hash=sha256:8f127e7b028900421cad64f51f75c051b628db17fb00e099eb148761eed598c9 \ 283 | --hash=sha256:94cdff45173b1919350601f82d61365e792895e3c3a3443cf99819e6fbf717a5 \ 284 | --hash=sha256:99d92d148dd03fd19d16175b6d355cc1b01faf80dae93c6c3eb4163709edc0a9 \ 285 | --hash=sha256:9a3049a10261d7f2b6514d35bbb7a4dfc3ece4c4de14ef5876c4b7a23a0e566d \ 286 | --hash=sha256:9d9a62576b68cd90f7075876f4e8444487db5eeea0e4df3ba298ee38a8d067b0 \ 287 | --hash=sha256:9e5f94742033898bfe84c93c831a6f552bb629448d4072dd312306bab3bd96f1 \ 288 | --hash=sha256:a1c2d7780448eb93fbcc3789bf3916aa5720d942e37945f4056680317f1cd23e \ 289 | --hash=sha256:a2e0f87144fcbbe54297cae708c5e7f9da21a4646523456b00cc956bd4c65815 \ 290 | --hash=sha256:a4dfdae195335abb4e89cc9762b2edc524f3c6e80d647a9a81bf81e17e3fb6f0 \ 291 | --hash=sha256:a96e6e23f2b79433390273eaf8cc94fec9c6370842e577ab10dabdcc7ea0a66b \ 292 | --hash=sha256:aabdab8ec1e7ca7f1434d042bf8b1e92056245fb179790dc97ed040361f16bfd \ 293 | --hash=sha256:b222090c455d6d1a64e6b7bb5f4035c4dff479e22455c9eaa1bdd4c75b52c80c \ 294 | --hash=sha256:b52ff4f4e002f828ea6483faf4c4e8deea8d743cf801b74910243c58acc6eda3 \ 295 | --hash=sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab \ 296 | --hash=sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858 \ 297 | --hash=sha256:b9b752ab91e78234941e44abdecc07f1f0d8f51fb62941d32995b8161f68cfe5 \ 298 | --hash=sha256:ba6612b6548220ff5e9df85261bddc811a057b0b465a1226b39bfb8550616aee \ 299 | --hash=sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343 \ 300 | --hash=sha256:c3c4ed2ff6760e98d262e0cc9c9a7f7b8a9f61aa4d47c58835cdaf7b0b8811bb \ 301 | --hash=sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47 \ 302 | --hash=sha256:cb362e3b0976dc994857391b776ddaa8c13c28a16f80ac6522c23d5257156bed \ 303 | --hash=sha256:d197df5489004db87d90b918033edbeee0bd6df3848a204bca3ff0a903bef837 \ 304 | --hash=sha256:d3b56206244dc8711f7e8b7d6cad4663917cd5b2d950799425076681e8766286 \ 305 | --hash=sha256:d5b2f8a31bd43e0f18172d8ac82347c8f37ef3e0b414431157718aa234991b28 \ 306 | --hash=sha256:d7081c084ceb58278dd3cf81f836bc818978c0ccc770cbbb202125ddabec6628 \ 307 | --hash=sha256:db74f5562c09953b2c5f8ec4b7dfd3f5421f31811e97d1dbc0a7c93d6e3a24df \ 308 | --hash=sha256:df41112ccce5d47770a0c13651479fbcd8793f34232a2dd9faeccb75eb5d0d0d \ 309 | --hash=sha256:e1339790c083c5a4de48f688b4841f18df839eb3c9584a770cbd818b33e26d5d \ 310 | --hash=sha256:e621b0246192d3b9cb1dc62c78cfa4c6f6d2ddc0ec207d43c0dedecb914f152a \ 311 | --hash=sha256:e8c5cf126889a4de385c02a2c3d3aba4b00f70234bfddae82a5eaa3ee6d5e3e6 \ 312 | --hash=sha256:e9d7747847c53a16a729b6ee5e737cf170f7a16611c143d95aa60a109a59c336 \ 313 | --hash=sha256:eaef5d2de3c7e9b21f1e762f289d17b726c2239a42b11e25446abf82b26ac132 \ 314 | --hash=sha256:ed3e4b4e1e6de75fdc16d3259098de7c6571b1a6cc863b1a49e7d3d53e036070 \ 315 | --hash=sha256:ef21af928e807f10bf4141cad4746eee692a0dd3ff56cfb25fce076ec3cc8abe \ 316 | --hash=sha256:f09598b416ba39a8f489c124447b007fe865f786a89dbfa48bb5cf395693132a \ 317 | --hash=sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd \ 318 | --hash=sha256:f6e78171be3fb7941f9910ea15b4b14ec27725865a73c15277bc39f5ca4f8391 \ 319 | --hash=sha256:f715c32e774a60a337b2bb8ad9839b4abf75b267a0f18806f6f4f5f1688c4b5a \ 320 | --hash=sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12 321 | # via matplotlib 322 | pluggy==1.0.0 \ 323 | --hash=sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159 \ 324 | --hash=sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3 325 | # via pytest 326 | pyparsing==3.0.9 \ 327 | --hash=sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb \ 328 | --hash=sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc 329 | # via matplotlib 330 | pytest==7.2.1 \ 331 | --hash=sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5 \ 332 | --hash=sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42 333 | # via 334 | # -r requirements.in 335 | # pytest-django 336 | pytest-django==4.5.2 \ 337 | --hash=sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e \ 338 | --hash=sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2 339 | # via -r requirements.in 340 | python-dateutil==2.8.2 \ 341 | --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ 342 | --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 343 | # via 344 | # faker 345 | # matplotlib 346 | six==1.16.0 \ 347 | --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ 348 | --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 349 | # via python-dateutil 350 | sqlparse==0.4.3 \ 351 | --hash=sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34 \ 352 | --hash=sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268 353 | # via django 354 | tqdm==4.64.1 \ 355 | --hash=sha256:5f4f682a004951c1b450bc753c710e9280c5746ce6ffedee253ddbcbf54cf1e4 \ 356 | --hash=sha256:6fee160d6ffcd1b1c68c65f14c829c22832bc401726335ce92c52d395944a6a1 357 | # via -r requirements.in 358 | -------------------------------------------------------------------------------- /requirements/requirements.in: -------------------------------------------------------------------------------- 1 | django 2 | matplotlib 3 | pytest 4 | pytest-django 5 | faker 6 | tqdm 7 | weasyprint==52.5 8 | django-weasyprint==1.1.0.post2 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terminalkitten/django-insights/054cbd8e50c1c4ce21166d875251631583f679b9/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core import management 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.test import TestCase, override_settings 5 | 6 | from django_insights.database import check_settings 7 | from django_insights.models import Counter 8 | from django_insights.registry import registry 9 | from project.settings.test import DATABASES 10 | from project.testapp.users.models import AppUser 11 | from tests.utils import collect_insights 12 | 13 | COPY_DATABASES = DATABASES.copy() 14 | COPY_DATABASES.pop('insights') 15 | 16 | 17 | class ApiTests(TestCase): 18 | databases = ['default', 'insights'] 19 | 20 | @classmethod 21 | def setUpClass(cls) -> None: 22 | super().setUpClass() 23 | management.call_command('seed_db') 24 | collect_insights() 25 | 26 | @override_settings(DATABASES=COPY_DATABASES) 27 | def test_throw_error_no_insights_db_configured(self): 28 | with pytest.raises(ImproperlyConfigured): 29 | check_settings() 30 | 31 | def test_autodiscover_registry(self): 32 | assert registry.registered_insights 33 | 34 | def test_one_app_user_found(self): 35 | assert AppUser.objects.count() == 1 36 | 37 | def test_count_authors(self): 38 | counter = Counter.objects.get(label="count_authors") 39 | assert counter.value == 2000 40 | 41 | def test_count_books(self): 42 | counter = Counter.objects.get(label="count_books") 43 | assert counter.value == 5000 44 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_insights.registry import registry 4 | 5 | 6 | @pytest.mark.django_db(True) 7 | def collect_insights() -> None: 8 | from django_insights.metrics import metrics 9 | 10 | registry.autodiscover_insights() 11 | metrics.collect() 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py38-django{32,40,41,42} 4 | py39-django{32,40,41,42} 5 | py310-django{32,40,41,42} 6 | py311-django{41,42} 7 | 8 | [testenv] 9 | commands = 10 | python \ 11 | -W error::ResourceWarning \ 12 | -W error::DeprecationWarning \ 13 | -W error::PendingDeprecationWarning \ 14 | -m pytest {posargs:tests} 15 | deps = -r requirements/{envname}.txt 16 | setenv = 17 | PYTHONDEVMODE=1 --------------------------------------------------------------------------------