diff options
31 files changed, 1215 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47361ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.python-version
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f857b4 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Coding Assignment Project diff --git a/coding_assignment_project/__init__.py b/coding_assignment_project/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/coding_assignment_project/__init__.py diff --git a/coding_assignment_project/settings.py b/coding_assignment_project/settings.py new file mode 100644 index 0000000..9afeb50 --- /dev/null +++ b/coding_assignment_project/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for coding_assignment_project project. + +Generated by 'django-admin startproject' using Django 2.1.2. + +For more information on this file, see +https://docs.djangoproject.com/en/2.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.1/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/2.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '6e+_000iv_ee1gv)^ls6%%x(=veqnx^k!))0t12x!^p&mou@1i' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'procurement.apps.ProcurementConfig', +] + +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 = 'coding_assignment_project.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': ['coding_assignment_project/templates'], + '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 = 'coding_assignment_project.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/2.1/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/2.1/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/2.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'Canada/Saskatchewan' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.1/howto/static-files/ + +STATIC_URL = '/static/' +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'coding_assignment_project', 'static') +] diff --git a/coding_assignment_project/static/css/lightsource-styles.css b/coding_assignment_project/static/css/lightsource-styles.css new file mode 100644 index 0000000..7ada411 --- /dev/null +++ b/coding_assignment_project/static/css/lightsource-styles.css @@ -0,0 +1,16 @@ +.lightsource-header { + background-color: #008080; +} +.navbar-default .navbar-brand, +.navbar-default .navbar-brand:focus, +.navbar-default .navbar-brand:hover { + color: #fff; +} +.panel-default>.panel-heading { + background-color: #008080; + color: #fff; +} + +.btn-primary { + background-color: #008080; +}
\ No newline at end of file diff --git a/coding_assignment_project/static/css/sb-admin-2.css b/coding_assignment_project/static/css/sb-admin-2.css new file mode 100644 index 0000000..e8be396 --- /dev/null +++ b/coding_assignment_project/static/css/sb-admin-2.css @@ -0,0 +1,354 @@ +/*! + * Start Bootstrap - SB Admin 2 Bootstrap Admin Theme (http://startbootstrap.com) + * Code licensed under the Apache License v2.0. + * For details, see http://www.apache.org/licenses/LICENSE-2.0. + */ + +body { + background-color: #f8f8f8; +} + +#wrapper { + width: 100%; +} + +#page-wrapper { + padding: 0 15px; + min-height: 568px; + background-color: #fff; +} + +@media(min-width:768px) { + #page-wrapper { + position: inherit; + margin: 0 0 0 250px; + padding: 0 30px; + border-left: 1px solid #e7e7e7; + } +} + +.navbar-top-links { + margin-right: 0; +} + +.navbar-top-links li { + display: inline-block; +} + +.navbar-top-links li:last-child { + margin-right: 15px; +} + +.navbar-top-links li a { + padding: 15px; + min-height: 50px; +} + +.navbar-top-links .dropdown-menu li { + display: block; +} + +.navbar-top-links .dropdown-menu li:last-child { + margin-right: 0; +} + +.navbar-top-links .dropdown-menu li a { + padding: 3px 20px; + min-height: 0; +} + +.navbar-top-links .dropdown-menu li a div { + white-space: normal; +} + +.navbar-top-links .dropdown-messages, +.navbar-top-links .dropdown-tasks, +.navbar-top-links .dropdown-alerts { + width: 310px; + min-width: 0; +} + +.navbar-top-links .dropdown-messages { + margin-left: 5px; +} + +.navbar-top-links .dropdown-tasks { + margin-left: -59px; +} + +.navbar-top-links .dropdown-alerts { + margin-left: -123px; +} + +.navbar-top-links .dropdown-user { + right: 0; + left: auto; +} + +.sidebar .sidebar-nav.navbar-collapse { + padding-right: 0; + padding-left: 0; +} + +.sidebar .sidebar-search { + padding: 15px; +} + +.sidebar ul li { + border-bottom: 1px solid #e7e7e7; +} + +.sidebar ul li a.active { + background-color: #eee; +} + +.sidebar .arrow { + float: right; +} + +.sidebar .fa.arrow:before { + content: "\f104"; +} + +.sidebar .active>a>.fa.arrow:before { + content: "\f107"; +} + +.sidebar .nav-second-level li, +.sidebar .nav-third-level li { + border-bottom: 0!important; +} + +.sidebar .nav-second-level li a { + padding-left: 37px; +} + +.sidebar .nav-third-level li a { + padding-left: 52px; +} + +@media(min-width:768px) { + .sidebar { + z-index: 1; + position: absolute; + width: 250px; + margin-top: 51px; + } + + .navbar-top-links .dropdown-messages, + .navbar-top-links .dropdown-tasks, + .navbar-top-links .dropdown-alerts { + margin-left: auto; + } +} + +.btn-outline { + color: inherit; + background-color: transparent; + transition: all .5s; +} + +.btn-primary.btn-outline { + color: #428bca; +} + +.btn-success.btn-outline { + color: #5cb85c; +} + +.btn-info.btn-outline { + color: #5bc0de; +} + +.btn-warning.btn-outline { + color: #f0ad4e; +} + +.btn-danger.btn-outline { + color: #d9534f; +} + +.btn-primary.btn-outline:hover, +.btn-success.btn-outline:hover, +.btn-info.btn-outline:hover, +.btn-warning.btn-outline:hover, +.btn-danger.btn-outline:hover { + color: #fff; +} + +.chat { + margin: 0; + padding: 0; + list-style: none; +} + +.chat li { + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px dotted #999; +} + +.chat li.left .chat-body { + margin-left: 60px; +} + +.chat li.right .chat-body { + margin-right: 60px; +} + +.chat li .chat-body p { + margin: 0; +} + +.panel .slidedown .glyphicon, +.chat .glyphicon { + margin-right: 5px; +} + +.chat-panel .panel-body { + height: 350px; + overflow-y: scroll; +} + +.login-panel { + margin-top: 25%; +} + +.flot-chart { + display: block; + height: 400px; +} + +.flot-chart-content { + width: 100%; + height: 100%; +} + +.dataTables_wrapper { + position: relative; + clear: both; +} + +table.dataTable thead .sorting, +table.dataTable thead .sorting_asc, +table.dataTable thead .sorting_desc, +table.dataTable thead .sorting_asc_disabled, +table.dataTable thead .sorting_desc_disabled { + background: 0 0; +} + +table.dataTable thead .sorting_asc:after { + content: "\f0de"; + float: right; + font-family: fontawesome; +} + +table.dataTable thead .sorting_desc:after { + content: "\f0dd"; + float: right; + font-family: fontawesome; +} + +table.dataTable thead .sorting:after { + content: "\f0dc"; + float: right; + font-family: fontawesome; + color: rgba(50,50,50,.5); +} + +.btn-circle { + width: 30px; + height: 30px; + padding: 6px 0; + border-radius: 15px; + text-align: center; + font-size: 12px; + line-height: 1.428571429; +} + +.btn-circle.btn-lg { + width: 50px; + height: 50px; + padding: 10px 16px; + border-radius: 25px; + font-size: 18px; + line-height: 1.33; +} + +.btn-circle.btn-xl { + width: 70px; + height: 70px; + padding: 10px 16px; + border-radius: 35px; + font-size: 24px; + line-height: 1.33; +} + +.show-grid [class^=col-] { + padding-top: 10px; + padding-bottom: 10px; + border: 1px solid #ddd; + background-color: #eee!important; +} + +.show-grid { + margin: 15px 0; +} + +.huge { + font-size: 40px; +} + +.panel-green { + border-color: #5cb85c; +} + +.panel-green .panel-heading { + border-color: #5cb85c; + color: #fff; + background-color: #5cb85c; +} + +.panel-green a { + color: #5cb85c; +} + +.panel-green a:hover { + color: #3d8b3d; +} + +.panel-red { + border-color: #d9534f; +} + +.panel-red .panel-heading { + border-color: #d9534f; + color: #fff; + background-color: #d9534f; +} + +.panel-red a { + color: #d9534f; +} + +.panel-red a:hover { + color: #b52b27; +} + +.panel-yellow { + border-color: #f0ad4e; +} + +.panel-yellow .panel-heading { + border-color: #f0ad4e; + color: #fff; + background-color: #f0ad4e; +} + +.panel-yellow a { + color: #f0ad4e; +} + +.panel-yellow a:hover { + color: #df8a13; +}
\ No newline at end of file diff --git a/coding_assignment_project/static/js/sb-admin-2.js b/coding_assignment_project/static/js/sb-admin-2.js new file mode 100644 index 0000000..96bc576 --- /dev/null +++ b/coding_assignment_project/static/js/sb-admin-2.js @@ -0,0 +1,31 @@ + +//Loads the correct sidebar on window load, +//collapses the sidebar on window resize. +// Sets the min-height of #page-wrapper to window size +$(function() { + $(window).bind("load resize", function() { + topOffset = 50; + width = (this.window.innerWidth > 0) ? this.window.innerWidth : this.screen.width; + if (width < 768) { + $('div.navbar-collapse').addClass('collapse'); + topOffset = 100; // 2-row-menu + } else { + $('div.navbar-collapse').removeClass('collapse'); + } + + height = ((this.window.innerHeight > 0) ? this.window.innerHeight : this.screen.height) - 1; + height = height - topOffset; + if (height < 1) height = 1; + if (height > topOffset) { + $("#page-wrapper").css("min-height", (height) + "px"); + } + }); + + var url = window.location; + var element = $('ul.nav a').filter(function() { + return this.href == url || url.href.indexOf(this.href) == 0; + }).addClass('active').parent().parent().addClass('in').parent(); + if (element.is('li')) { + element.addClass('active'); + } +}); diff --git a/coding_assignment_project/templates/__init__.py b/coding_assignment_project/templates/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/coding_assignment_project/templates/__init__.py diff --git a/coding_assignment_project/templates/base.html b/coding_assignment_project/templates/base.html new file mode 100644 index 0000000..231c385 --- /dev/null +++ b/coding_assignment_project/templates/base.html @@ -0,0 +1,114 @@ +{% load static %} +<!DOCTYPE html> +<html lang="en"> + +<head> + + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="description" content=""> + <meta name="author" content=""> + + <title>Procurement Dashboard</title> + + <!-- Bootstrap Core CSS --> + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> + + <!-- Custom CSS --> + <link href="{% static 'css/sb-admin-2.css' %}" rel="stylesheet"> + + <!-- Font Awesome --> + <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous"> + + <!-- Lightsource Custom Styles --> + <link href="{% static 'css/lightsource-styles.css' %}" rel="stylesheet" type="text/css"> + + {% block additional_css %} + {% endblock %} +</head> + +<body> + + <div id="wrapper"> + + <!-- Navigation --> + <nav class="navbar navbar-default navbar-static-top lightsource-header" role="navigation" style="margin-bottom: 0"> + <div class="navbar-header"> + <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> + <span class="sr-only">Toggle navigation</span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + </button> + <a class="navbar-brand" href="{% url 'component-search' %}">Light Source</a> + </div> + <!-- /.navbar-header --> + + + <div class="navbar-default sidebar" role="navigation"> + <div class="sidebar-nav navbar-collapse"> + <ul class="nav" id="side-menu"> + <li class="sidebar-search"> + <div class="input-group"> + <h4><small></small></h4> + </div> + <!-- /input-group --> + </li> + <li> + <a href="{% url 'documentation' %}" {% if page_name == "documentation" %} class="active"{% endif %}><i class="fa fa-info-circle fa-fw"></i> Documentation</a> + </li> + <li> + <a href="{% url 'component-search' %}" {% if page_name == "source components" %} class="active"{% endif %}><i class="fa fa-th-list fa-fw"></i> Source Components</a> + </li> + + </ul> + </div> + <!-- /.sidebar-collapse --> + </div> + <!-- /.navbar-static-side --> + </nav> + + <div id="page-wrapper"> + <div class="row" id="title-bar"> + <div class="col-lg-12"> + <h1 class="page-header">{{ page_name | title }}</h1> + </div> + <!-- /.col-lg-12 --> + </div> + + <!-- /.row --> + <div class="row"> + {% block main_content_area %} + {% endblock %} + </div> + <!-- /.row --> + </div> + <!-- /#page-wrapper --> + + </div> + <!-- /#wrapper --> + + <!-- jQuery --> + <script + src="https://code.jquery.com/jquery-3.3.1.min.js" + integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" + crossorigin="anonymous"></script> + + <script + src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" + integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" + crossorigin="anonymous"></script> + + <!-- Bootstrap Core JavaScript --> + <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script> + + <!-- Custom Theme JavaScript --> + <script src="{% static 'js/sb-admin-2.js' %}"></script> + + {% block additional_js %} + {% endblock %} + +</body> + +</html> diff --git a/coding_assignment_project/urls.py b/coding_assignment_project/urls.py new file mode 100644 index 0000000..7fcafc7 --- /dev/null +++ b/coding_assignment_project/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('procurement.urls')) +] diff --git a/coding_assignment_project/wsgi.py b/coding_assignment_project/wsgi.py new file mode 100644 index 0000000..3057f8d --- /dev/null +++ b/coding_assignment_project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for coding_assignment_project 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/2.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'coding_assignment_project.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..a171f51 --- /dev/null +++ b/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'coding_assignment_project.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) diff --git a/procurement/__init__.py b/procurement/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/procurement/__init__.py diff --git a/procurement/admin.py b/procurement/admin.py new file mode 100644 index 0000000..993b1b5 --- /dev/null +++ b/procurement/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin +from django.shortcuts import render_to_response, get_object_or_404 + +from procurement.admin_forms import ComponentAdminForm +from procurement.models import Supplier, Component + + +class SupplierAdmin(admin.ModelAdmin): + list_display = ('name', 'representative_name', 'representative_email', 'is_authorized', 'updated') + filter_horizonal = ('components',) + + +class ComponentAdmin(admin.ModelAdmin): + list_display = ('name', 'sku', 'updated') + form = ComponentAdminForm + source_components_template = 'procurement/admin_templates/source_components.html' + + def source_components(self, request, pk): + component = get_object_or_404(Component, pk=pk) + + return render_to_response(self.source_components_template, { + 'title': 'Source Suppliers for: %s' % component, + 'opts': self.model._meta, + 'component': component, + 'supplier_results': component.suppliers.filter(is_authorized=True), + + }) + + +admin.site.register(Supplier, SupplierAdmin) +admin.site.register(Component, ComponentAdmin) diff --git a/procurement/admin_forms.py b/procurement/admin_forms.py new file mode 100644 index 0000000..b7bbd1c --- /dev/null +++ b/procurement/admin_forms.py @@ -0,0 +1,20 @@ +from django import forms +from django.contrib.admin.widgets import FilteredSelectMultiple + +from procurement.models import Supplier, Component + + +class ComponentAdminForm(forms.ModelForm): + suppliers = forms.ModelMultipleChoiceField( + queryset=Supplier.objects.filter(is_authorized=True), + required=False, + widget=FilteredSelectMultiple( + verbose_name='Suppliers', + is_stacked=False + ) + + ) + + class Meta: + model = Component + fields = ['name', 'sku', 'suppliers']
\ No newline at end of file diff --git a/procurement/api.py b/procurement/api.py new file mode 100644 index 0000000..0419ce8 --- /dev/null +++ b/procurement/api.py @@ -0,0 +1,14 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView + +from procurement.models import Component +from procurement.serializers import ComponentSerializer + + +class ComponentAPIList(ListAPIView): + queryset = Component.objects.all() + serializer_class = ComponentSerializer + + +class ComponentAPIRetrieve(RetrieveAPIView): + queryset = Component.objects.all() + serializer_class = ComponentSerializer diff --git a/procurement/apps.py b/procurement/apps.py new file mode 100644 index 0000000..94c7e8e --- /dev/null +++ b/procurement/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProcurementConfig(AppConfig): + name = 'procurement' diff --git a/procurement/forms.py b/procurement/forms.py new file mode 100644 index 0000000..4e8b0aa --- /dev/null +++ b/procurement/forms.py @@ -0,0 +1,14 @@ +from django import forms +from procurement.models import Component + + +class ComponentSearchForm(forms.Form): + component = forms.ModelChoiceField( + queryset=Component.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + super(ComponentSearchForm, self).__init__(*args, **kwargs) + self.fields['component'].widget.attrs.update({"class": "form-control"}) + diff --git a/procurement/migrations/0001_initial.py b/procurement/migrations/0001_initial.py new file mode 100644 index 0000000..ffabc0e --- /dev/null +++ b/procurement/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 2.1.2 on 2018-10-02 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Component', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('sku', models.CharField(max_length=50)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Supplier', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255)), + ('representative_name', models.CharField(blank=True, max_length=255, null=True)), + ('representative_email', models.EmailField(blank=True, max_length=255, null=True)), + ('is_authorized', models.BooleanField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='component', + name='suppliers', + field=models.ManyToManyField(blank=True, related_name='components', to='procurement.Supplier'), + ), + ] diff --git a/procurement/migrations/__init__.py b/procurement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/procurement/migrations/__init__.py diff --git a/procurement/models.py b/procurement/models.py new file mode 100644 index 0000000..1e70736 --- /dev/null +++ b/procurement/models.py @@ -0,0 +1,83 @@ +from django.db import models +from django.utils.timezone import now as timezone_now + +MONTH = 30 * 24 * 60 * 60 +WEEK = 7 * 24 * 60 * 60 +DAY = 24 * 60 * 60 +HOUR = 60 * 60 +MINUTE = 60 + + +class DashboardModel(models.Model): + """ + Abstract base model for things which will be displayed on the dashboard, adds in created and updated fields, + and provides a convenience method which provides a nicely formatted string of the time since update. + """ + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + @property + def time_since_update(self): + update_delta = timezone_now() - self.updated + seconds_since_update = update_delta.seconds + + if seconds_since_update / MONTH >= 1: + quantity = seconds_since_update / MONTH + units = 'months' if quantity > 1 else 'month' + + elif seconds_since_update / WEEK >= 1: + quantity = seconds_since_update / WEEK + units = 'weeks' if quantity > 1 else 'week' + + elif seconds_since_update / DAY >= 1: + quantity = seconds_since_update / DAY + units = 'days' if quantity > 1 else 'day' + + elif seconds_since_update / HOUR >= 1: + quantity = seconds_since_update / HOUR + units = 'hours' if quantity > 1 else 'hour' + + elif seconds_since_update / MINUTE >= 1: + quantity = seconds_since_update / MINUTE + units = 'minutes' if quantity > 1 else 'minute' + + else: + return "updated just now" + + # Ensure the quantity output is rounded to 2 decimal places + base_string = 'updated {quantity:.2f} {units} ago' + return base_string.format(quantity=quantity, units=units) + + +class Supplier(DashboardModel): + """ + Model which represents an individual or organisation which supplies components + """ + name = models.CharField(max_length=255) + representative_name = models.CharField(max_length=255, null=True, blank=True) + representative_email = models.EmailField(max_length=255, null=True, blank=True) + is_authorized = models.BooleanField() + + def __str__(self): + return '{}'.format(self.name) + + +class Component(DashboardModel): + """ + Model which represents items which may be supplied. + """ + name = models.CharField(max_length=255) + sku = models.CharField(max_length=50) + suppliers = models.ManyToManyField(Supplier, related_name='components', blank=True) + + class Meta: + ordering = ("name",) + + def __str__(self): + return '{} ({})'.format( + self.name, + self.sku + ) diff --git a/procurement/serializers.py b/procurement/serializers.py new file mode 100644 index 0000000..1840837 --- /dev/null +++ b/procurement/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from procurement.models import Component, Supplier + + +class SupplierSerializer(serializers.ModelSerializer): + class Meta: + model = Supplier + exclude = ('created', 'updated') + + +class ComponentSerializer(serializers.ModelSerializer): + text = serializers.CharField(source='__str__', read_only=True) + suppliers = SupplierSerializer(many=True, read_only=True) + + class Meta: + model = Component + exclude = ('created', 'updated') diff --git a/procurement/static/procurement/css/suppliers.css b/procurement/static/procurement/css/suppliers.css new file mode 100644 index 0000000..396a446 --- /dev/null +++ b/procurement/static/procurement/css/suppliers.css @@ -0,0 +1,13 @@ +/* hide component field on search form */ +#id_component { + display: none; +} +label[for=id_component] { + display: none; +} +.record-list { + display: block; +} +.record-updated{ + display: block; +}
\ No newline at end of file diff --git a/procurement/static/procurement/js/component_search.js b/procurement/static/procurement/js/component_search.js new file mode 100644 index 0000000..cba0a16 --- /dev/null +++ b/procurement/static/procurement/js/component_search.js @@ -0,0 +1,3 @@ +$(document).ready(function(){ + $("#id_component").select2(); +});
\ No newline at end of file diff --git a/procurement/templates/procurement/admin_templates/source_components.html b/procurement/templates/procurement/admin_templates/source_components.html new file mode 100644 index 0000000..2deae1e --- /dev/null +++ b/procurement/templates/procurement/admin_templates/source_components.html @@ -0,0 +1,45 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_modify %} +{% block extrahead %}{{ block.super }} + <!-- Bootstrap Core CSS --> + <link href="/static/prebuilt/bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"> + <!-- Lightsource Custom Styles --> + <link href="/static/custom/css/lightsource-styles.css" rel="stylesheet" type="text/css"> + {% for stylesheet in module_stylesheets %} + <link href="/static/{{ stylesheet }}" rel="stylesheet" type="text/css"> + {% endfor %} +{% url 'admin:jsi18n' as jsi18nurl %} +{% endblock %} +{% block extrastyle %}{{ block.super }} +{% endblock %} +{% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} change-form{% endblock %} +{% block breadcrumbs %}{% if not is_popup %} +<div class="breadcrumbs"> + <a href="../../">{% trans "Home" %}</a> › + <a href="../">{{ opts.app_label|capfirst|escape }}</a> › + {% trans 'Source Components' %}</div> +{% endif %}{% endblock %} + +{% block content %} + <div class="col-lg-8"> + + <!-- /.panel --> + {% if supplier_results != None %} + {% include "suppliers/includes/supplier_list.html" %} + {% endif %} + </div> + + <!-- jQuery --> + <script src="/static/prebuilt/bower_components/jquery/dist/jquery.min.js"></script> + <script src="//code.jquery.com/ui/1.11.4/jquery-ui.js"></script> + <link rel="stylesheet" href="//code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css"> + + <!-- Bootstrap Core JavaScript --> + <script src="/static/prebuilt/bower_components/bootstrap/dist/js/bootstrap.min.js"></script> + + <!-- Custom Theme JavaScript --> + <script src="/static/prebuilt/dist/js/sb-admin-2.js"></script> + {% for script in module_javascripts %} + <script src="/static/{{ script }}"></script> + {% endfor %} +{% endblock %}
\ No newline at end of file diff --git a/procurement/templates/procurement/documentation.html b/procurement/templates/procurement/documentation.html new file mode 100644 index 0000000..4775890 --- /dev/null +++ b/procurement/templates/procurement/documentation.html @@ -0,0 +1,76 @@ +{% extends 'base.html' %} + +{% block main_content_area %} + <div class="col-lg-4"> + <div class="panel panel-default"> + <div class="panel-heading"> + Documentation + </div> + <!-- /.panel-heading --> + <div class="panel-body"> + <div class="list-group"> + <a href="#introduction" class="list-group-item"> + <i class="fa fa-info-circle fa-fw"></i> Introduction + </a> + <a href="#adding-data" class="list-group-item"> + <i class="fa fa-database fa-fw"></i> Adding Data + </a> + <a href="#supplier-list" class="list-group-item"> + <i class="fa fa-th-list fa-fw"></i> Supplier List + </a> + <a href="#api" class="list-group-item"> + <i class="fa fa-bolt fa-fw"></i> API + </a> + </div> + <!-- /.list-group --> + </div> + <!-- /.panel-body --> + </div> + <!-- /.panel --> + </div> + + <div class="col-lg-8"> + <div class="panel panel-default"> + <div class="panel-body"> + <h2><a name="introduction"></a>Introduction</h2> + <p> + This is a very simple component and supplier management system. The intention function of the system + is to maintain a list of components, and the suppliers who are authorised to supply them. Administrative + staff maintain the list of suppliers, components and their associations, while general staff simply + use the from page to look up a component, and see its list of suppliers. + </p> + <p> + The system is written in Python (v3.6), using the Django framework (v2.1.2). Documentation for Django + is available <a href="https://www.djangoproject.com/">here</a>. + </p> + + + <h2><a name="adding-data"></a>Adding Data</h2> + <p> + You can manage the components and suppliers using the + <a href="{% url 'admin:index' %}">django admin panel</a>. + </p> + + <h2><a name="supplier-list"></a>Supplier List</h2> + <p> + Components and their approved Suppliers can be located under + <a href="{% url 'component-search' %}">source components</a>. From this page you may search for a + component, and then click the Find Suppliers button to display its Suppliers + </p> + + <h2><a name="api"></a>API</h2> + <p> + A simple REST api is integrated into the system, using the <a href="https://www.django-rest-framework.org/">Django Rest Framework</a>. + It provides a means of querying <a href="{% url 'api-component-list' %}">all components</a> or, by providing an id + in the url, <a href="{% url 'api-component-retrieve' pk=1 %}">a specific component</a>. + </p> + </div> + <!-- /.panel-body --> + </div> + <!-- /.panel --> + </div> + + + + +{% endblock %}
\ No newline at end of file diff --git a/procurement/templates/procurement/includes/supplier_list.html b/procurement/templates/procurement/includes/supplier_list.html new file mode 100644 index 0000000..028e62c --- /dev/null +++ b/procurement/templates/procurement/includes/supplier_list.html @@ -0,0 +1,37 @@ + +<!-- Supplier Results --> +<div class="panel panel-default"> + <div class="panel-heading"> + Approved Suppliers for {{ component }} + </div> + <!-- /.panel-heading --> + <div class="panel-body"> + <div class="table-responsive"> + <table class="table"> + <thead> + <tr> + <th>Company</th> + <th>Representative</th> + <th>Email</th> + </tr> + </thead> + <tbody> + {% for supplier in supplier_results %} + <tr> + <td>{{ supplier.name }}</td> + <td>{{ supplier.representative_name }}</td> + <td>{{ supplier.representative_email }}</td> + </tr> + {% empty %} + <tr> + <td colspan="3">No authorized suppliers found.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + <!-- /.table-responsive --> + </div> + <!-- /.panel-body --> +</div> +<!-- / Supplier Results Panel -->
\ No newline at end of file diff --git a/procurement/templates/procurement/source_components.html b/procurement/templates/procurement/source_components.html new file mode 100644 index 0000000..3eebf84 --- /dev/null +++ b/procurement/templates/procurement/source_components.html @@ -0,0 +1,61 @@ +{% extends 'base.html' %} +{% load static %} + +{% block additional_css %} + <link href="{% static 'procurement/css/suppliers.css' %}" rel="stylesheet" type="text/css"> + <link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/css/select2.min.css" rel="stylesheet" /> +{% endblock %} + +{% block main_content_area %} + <div class="col-lg-8"> + <div class="panel panel-default"> + <div class="panel-body"> + <form id="find-suppliers" method="post"> + <div class="form-group"> + {% csrf_token %} + {{ form }} + </div> + <div class="form-group"> + <input type="submit" value="Find Suppliers" id="find-suppliers-button" class="btn btn-primary"> + </div> + </form> + </div> + <!-- /.panel-body --> + </div> + <!-- /.panel --> + {% if supplier_results != None %} + {% include 'procurement/includes/supplier_list.html' %} + {% endif %} + </div> + + + <div class="col-lg-4"> + <div class="panel panel-default"> + <div class="panel-heading"> + Searching Procurement Records + </div> + <!-- /.panel-heading --> + <div class="panel-body"> + <div class="list-group"> + <span class="list-group-item"> + <span class="record-list"><i class="fa fa-parachute-box fa-fw"></i> {{ supplier_count }} Suppliers</span> + <span class="record-updated text-muted small"><em>{{ suppliers_last_updated }}</em></span> + </span> + <span class="list-group-item"> + <span class="record-list"><i class="fa fa-cog fa-fw"></i> {{ component_count }} Components</span> + <span class="record-updated text-muted small"><em>{{ components_last_updated }}</em></span> + </span> + </div> + <!-- /.list-group --> + </div> + <!-- /.panel-body --> + </div> + <!-- /.panel --> + </div> + +{% endblock %} + +{% block additional_js %} + <script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/js/select2.min.js"></script> + <script src="{% static 'procurement/js/component_search.js' %}"></script> +{% endblock %}
\ No newline at end of file diff --git a/procurement/urls.py b/procurement/urls.py new file mode 100644 index 0000000..d629635 --- /dev/null +++ b/procurement/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from procurement.views import ComponentSearchView, DocumentationView +from procurement.api import ComponentAPIList, ComponentAPIRetrieve + +urlpatterns = [ + path('', ComponentSearchView.as_view(), name='component-search'), + path('documentation', DocumentationView.as_view(), name='documentation'), + + # API Urls + path('api/components/', ComponentAPIList.as_view(), name='api-component-list'), + path('api/components/<int:pk>/', ComponentAPIRetrieve.as_view(), name='api-component-retrieve'), +] diff --git a/procurement/views.py b/procurement/views.py new file mode 100644 index 0000000..8891f89 --- /dev/null +++ b/procurement/views.py @@ -0,0 +1,54 @@ +from django.views.generic import FormView, TemplateView + +from procurement.forms import ComponentSearchForm +from procurement.models import Supplier, Component + + +class ComponentSearchView(FormView): + template_name = 'procurement/source_components.html' + form_class = ComponentSearchForm + + component = None + supplier_results = None + + def get_context_data(self): + context = super().get_context_data() + + try: + suppliers_last_updated = Supplier.objects.latest('updated').time_since_update + except Supplier.DoesNotExist: + suppliers_last_updated = '' + + try: + components_last_updated = Component.objects.latest('updated').time_since_update + except Component.DoesNotExist: + components_last_updated = '' + + context.update({ + 'page_name': 'Component Search', + 'component': self.component, + 'supplier_results': self.supplier_results, + 'supplier_count': Supplier.objects.all().count(), + 'suppliers_last_updated': suppliers_last_updated, + 'component_count': Component.objects.all().count(), + 'components_last_updated': components_last_updated, + }) + return context + + def form_valid(self, form): + self.component = form.cleaned_data['component'] + if self.component: + self.supplier_results = self.component.suppliers.filter(is_authorized=True) + + return super(ComponentSearchView, self).get(self.request) + + +class DocumentationView(TemplateView): + template_name = 'procurement/documentation.html' + + def get_context_data(self): + context = super().get_context_data() + context.update({ + 'page_name': 'Documentation', + }) + return context diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4ea5aaf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Django==2.1.2 +djangorestframework==3.8.2 |