Django example (#297)

* Add rough django example

* Remove sqlite db

* Add gitignore

* Fix flake8 and pydocstyle errors

* Add tests

* Refactor settings

* Move web app to to the root of the project

* Add bootstrap 4

* Add doc blocks for web app

* Add coverage

* Fix typo in flask

* Remove not needed newlines

* Add screenshot
This commit is contained in:
Roman Mogylatov 2020-10-04 23:36:45 -04:00 committed by GitHub
parent 92a0ba4815
commit 6094186063
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 598 additions and 2 deletions

1
examples/miniapps/django/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite3

View File

@ -0,0 +1,2 @@
[pydocstyle]
ignore = D100,D101,D102,D103,D105,D107,D203,D212,D213,D400,D406,D407,D411,D413,D415

View File

@ -0,0 +1,111 @@
Django + Dependency Injector Example
====================================
Application ``githubnavigator`` is a `Django <https://www.djangoproject.com/>`_ +
`Dependency Injector <http://python-dependency-injector.ets-labs.org/>`_ example application.
.. image:: screenshot.png
Run
---
Create virtual environment:
.. code-block:: bash
virtualenv venv
. venv/bin/activate
Install requirements:
.. code-block:: bash
pip install -r requirements.txt
Run migrations:
.. code-block:: bash
python manage.py migrate
To run the application do:
.. code-block:: bash
python manage.py runserver
The output should be something like:
.. code-block::
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
October 05, 2020 - 03:17:05
Django version 3.1.2, using settings 'githubnavigator.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
After that visit http://127.0.0.1:8000/ in your browser.
.. note::
Github has a rate limit. When the rate limit is exceed you will see an exception
``github.GithubException.RateLimitExceededException``. For unauthenticated requests, the rate
limit allows for up to 60 requests per hour. To extend the limit to 5000 requests per hour you
need to set personal access token.
It's easy:
- Follow this `guide <https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token>`_ to create a token.
- Set a token to the environment variable:
.. code-block:: bash
export GITHUB_TOKEN=<your token>
- Restart the app with ``python manage.py runserver``
`Read more on Github rate limit <https://developer.github.com/v3/#rate-limiting>`_
Test
----
This application comes with the unit tests.
To run the tests do:
.. code-block:: bash
coverage run --source='.' manage.py test && coverage report
The output should be something like:
.. code-block::
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.037s
OK
Destroying test database for alias 'default'...
Name Stmts Miss Cover
---------------------------------------------------
githubnavigator/__init__.py 4 0 100%
githubnavigator/asgi.py 4 4 0%
githubnavigator/containers.py 7 0 100%
githubnavigator/services.py 14 0 100%
githubnavigator/settings.py 23 0 100%
githubnavigator/urls.py 3 0 100%
githubnavigator/wsgi.py 4 4 0%
manage.py 12 2 83%
web/__init__.py 0 0 100%
web/apps.py 7 0 100%
web/tests.py 28 0 100%
web/urls.py 3 0 100%
web/views.py 11 0 100%
---------------------------------------------------
TOTAL 120 10 92%

View File

@ -0,0 +1,8 @@
"""Main package."""
from .containers import Container
from . import settings
container = Container()
container.config.from_dict(settings.__dict__)

View File

@ -0,0 +1,15 @@
"""ASGI config for githubnavigator project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'githubnavigator.settings')
application = get_asgi_application()

View File

@ -0,0 +1,22 @@
"""Containers module."""
from dependency_injector import containers, providers
from github import Github
from . import services
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
github_client = providers.Factory(
Github,
login_or_token=config.GITHUB_TOKEN,
timeout=config.GITHUB_REQUEST_TIMEOUT,
)
search_service = providers.Factory(
services.SearchService,
github_client=github_client,
)

View File

@ -0,0 +1,44 @@
"""Services module."""
from github import Github
from github.Repository import Repository
from github.Commit import Commit
class SearchService:
"""Search service performs search on Github."""
def __init__(self, github_client: Github):
self._github_client = github_client
def search_repositories(self, query, limit):
"""Search for repositories and return formatted data."""
repositories = self._github_client.search_repositories(
query=query,
**{'in': 'name'},
)
return [
self._format_repo(repository)
for repository in repositories[:limit]
]
def _format_repo(self, repository: Repository):
commits = repository.get_commits()
return {
'url': repository.html_url,
'name': repository.name,
'owner': {
'login': repository.owner.login,
'url': repository.owner.html_url,
'avatar_url': repository.owner.avatar_url,
},
'latest_commit': self._format_commit(commits[0]) if commits else {},
}
def _format_commit(self, commit: Commit):
return {
'sha': commit.sha,
'url': commit.html_url,
'message': commit.commit.message,
'author_name': commit.commit.author.name,
}

View File

@ -0,0 +1,131 @@
"""
Django settings for githubnavigator project.
Generated by 'django-admin startproject' using Django 3.0.8.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = ')6*iyg26c9l!fvyvwd&3+vyf-dcw)e=5x2t(j)(*c29z@ykhi0'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'web.apps.WebConfig',
'bootstrap4',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'githubnavigator.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'githubnavigator.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
# Github client settings
GITHUB_TOKEN = os.getenv('GITHUB_TOKEN')
GITHUB_REQUEST_TIMEOUT = 10
# Search settings
DEFAULT_LIMIT = 5
DEFAULT_QUERY = 'Dependency Injector'
LIMITS = [5, 10, 20]

View File

@ -0,0 +1,22 @@
"""githubnavigator URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('', include('web.urls')),
path('admin/', admin.site.urls),
]

View File

@ -0,0 +1,16 @@
"""
WSGI config for githubnavigator project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'githubnavigator.settings')
application = get_wsgi_application()

View File

@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'githubnavigator.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,5 @@
dependency-injector
django
django-bootstrap4
pygithub
coverage

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@ -0,0 +1 @@
"""Web application package."""

View File

@ -0,0 +1,13 @@
"""Application config module."""
from django.apps import AppConfig
from githubnavigator import container
from . import views
class WebConfig(AppConfig):
name = 'web'
def ready(self):
container.wire(modules=[views])

View File

@ -0,0 +1,10 @@
{% extends 'bootstrap4/bootstrap4.html' %}
{% load bootstrap4 %}
{% block bootstrap4_title %}{% block title %}{% endblock %}{% endblock %}
{% block bootstrap4_content %}
{% autoescape off %}{% bootstrap_messages %}{% endautoescape %}
{% block content %}(no content){% endblock %}
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Github Navigator{% endblock %}
{% block content %}
<div class="container">
<h1 class="mb-4">Github Navigator</h1>
<form>
<div class="form-group form-row">
<div class="col-10">
<label for="search_query" class="col-form-label">
Search for:
</label>
<input class="form-control" type="text" id="search_query"
placeholder="Type something to search on the GitHub"
name="query"
value="{{ query }}">
</div>
<div class="col">
<label for="search_limit" class="col-form-label">
Limit:
</label>
<select class="form-control" id="search_limit" name="limit">
{% for value in limits %}
<option {% if value == limit %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
</div>
</form>
<p><small>Results found: {{ repositories|length }}</small></p>
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Repository</th>
<th class="text-nowrap">Repository owner</th>
<th class="text-nowrap">Last commit</th>
</tr>
</thead>
<tbody>
{% for repository in repositories %} {{n}}
<tr>
<th>{{ loop.index }}</th>
<td><a href="{{ repository.url }}">
{{ repository.name }}</a>
</td>
<td><a href="{{ repository.owner.url }}">
<img src="{{ repository.owner.avatar_url }}"
alt="avatar" height="24" width="24"/></a>
<a href="{{ repository.owner.url }}">
{{ repository.owner.login }}</a>
</td>
<td><a href="{{ repository.latest_commit.url }}">
{{ repository.latest_commit.sha }}</a>
{{ repository.latest_commit.message }}
{{ repository.latest_commit.author_name }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,63 @@
"""Tests module."""
from unittest import mock
from django.urls import reverse
from django.test import TestCase
from github import Github
from githubnavigator import container
class IndexTests(TestCase):
def test_index(self):
github_client_mock = mock.Mock(spec=Github)
github_client_mock.search_repositories.return_value = [
mock.Mock(
html_url='repo1-url',
name='repo1-name',
owner=mock.Mock(
login='owner1-login',
html_url='owner1-url',
avatar_url='owner1-avatar-url',
),
get_commits=mock.Mock(return_value=[mock.Mock()]),
),
mock.Mock(
html_url='repo2-url',
name='repo2-name',
owner=mock.Mock(
login='owner2-login',
html_url='owner2-url',
avatar_url='owner2-avatar-url',
),
get_commits=mock.Mock(return_value=[mock.Mock()]),
),
]
with container.github_client.override(github_client_mock):
response = self.client.get(reverse('index'))
self.assertContains(response, 'Results found: 2')
self.assertContains(response, 'repo1-url')
self.assertContains(response, 'repo1-name')
self.assertContains(response, 'owner1-login')
self.assertContains(response, 'owner1-url')
self.assertContains(response, 'owner1-avatar-url')
self.assertContains(response, 'repo2-url')
self.assertContains(response, 'repo2-name')
self.assertContains(response, 'owner2-login')
self.assertContains(response, 'owner2-url')
self.assertContains(response, 'owner2-avatar-url')
def test_index_no_results(self):
github_client_mock = mock.Mock(spec=Github)
github_client_mock.search_repositories.return_value = []
with container.github_client.override(github_client_mock):
response = self.client.get(reverse('index'))
self.assertContains(response, 'Results found: 0')

View File

@ -0,0 +1,9 @@
"""URLs module."""
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
]

View File

@ -0,0 +1,34 @@
"""Views module."""
from typing import List
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from dependency_injector.wiring import Provide
from githubnavigator.containers import Container
from githubnavigator.services import SearchService
def index(
request: HttpRequest,
search_service: SearchService = Provide[Container.search_service],
default_query: str = Provide[Container.config.DEFAULT_QUERY],
default_limit: int = Provide[Container.config.DEFAULT_LIMIT.as_int()],
limits: List[int] = Provide[Container.config.LIMITS],
) -> HttpResponse:
query = request.GET.get('query', default_query)
limit = int(request.GET.get('limit', default_limit))
repositories = search_service.search_repositories(query, limit)
return render(
request,
template_name='index.html',
context={
'query': query,
'limit': limit,
'limits': limits,
'repositories': repositories,
}
)

View File

@ -46,8 +46,7 @@ After that visit http://127.0.0.1:5000/ in your browser.
.. note::
Github has a rate limit. When thre rate limit is exceed you will see an exception
Github has a rate limit. When the rate limit is exceed you will see an exception
``github.GithubException.RateLimitExceededException``. For unauthenticated requests, the rate
limit allows for up to 60 requests per hour. To extend the limit to 5000 requests per hour you
need to set personal access token.