├── projement ├── auth │ ├── __init__.py │ ├── apps.py │ ├── views.py │ ├── urls.py │ ├── forms.py │ └── tests.py ├── projects │ ├── __init__.py │ ├── rest │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── views.py │ │ └── serializers.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── tests.py │ ├── fixtures │ │ └── initial.json │ └── models.py ├── projement │ ├── __init__.py │ ├── rest │ │ ├── __init__.py │ │ └── urls.py │ ├── wsgi.py │ ├── urls.py │ ├── management │ │ └── commands │ │ │ └── loadmanyprojects.py │ └── settings.py ├── static │ └── styles-src │ │ ├── vendor.js │ │ ├── main.scss │ │ └── main.js ├── .gitignore ├── app │ ├── src │ │ ├── core │ │ │ ├── Navbar │ │ │ │ ├── index.js │ │ │ │ ├── Navbar.test.js │ │ │ │ └── Navbar.js │ │ │ ├── PrivateRoute │ │ │ │ ├── index.js │ │ │ │ ├── PrivateRoute.js │ │ │ │ └── PrivateRoute.test.js │ │ │ ├── testSetup.js │ │ │ ├── pages │ │ │ │ ├── index.js │ │ │ │ └── AssignmentPage.js │ │ │ ├── index.js │ │ │ ├── Spinner.js │ │ │ ├── RefreshPage.js │ │ │ ├── RefreshPageIfEmpty.js │ │ │ ├── store.js │ │ │ ├── testUtils.js │ │ │ └── App.js │ │ ├── projects │ │ │ ├── pages │ │ │ │ ├── DashboardPage │ │ │ │ │ ├── index.js │ │ │ │ │ ├── DashboardPage.test.js │ │ │ │ │ └── DashboardPage.js │ │ │ │ ├── EditProjectPage │ │ │ │ │ ├── index.js │ │ │ │ │ ├── EditProjectPage.js │ │ │ │ │ └── EditProjectPage.test.js │ │ │ │ └── index.js │ │ │ ├── forms │ │ │ │ ├── EditProjectForm │ │ │ │ │ ├── index.js │ │ │ │ │ ├── EditProjectForm.test.js │ │ │ │ │ └── EditProjectForm.js │ │ │ │ └── index.js │ │ │ ├── testUtils.js │ │ │ ├── propTypes.js │ │ │ └── ducks │ │ │ │ └── projects.js │ │ ├── main.js │ │ └── doc.css │ ├── webpack │ │ ├── set-public-path.js │ │ ├── config.dev.js │ │ └── config.base.js │ └── README.adoc ├── .prettierrc ├── setup.cfg ├── pyproject.toml ├── templates │ ├── app.html │ ├── base-server-rendered.html │ ├── registration │ │ └── login.html │ └── base.html ├── requirements.txt ├── .babelrc ├── .eslintrc ├── manage.py ├── README.adoc └── package.json ├── .gitignore ├── .dockerignore ├── .editorconfig ├── docker ├── Dockerfile-django └── Dockerfile-node ├── docker-compose.yml ├── Makefile └── README.adoc /projement/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/projects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/projement/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/projects/rest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/projement/rest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/static/styles-src/vendor.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/projects/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projement/.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | coverage_html 3 | db.sqlite3 4 | -------------------------------------------------------------------------------- /projement/app/src/core/Navbar/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Navbar'; 2 | -------------------------------------------------------------------------------- /projement/app/src/core/PrivateRoute/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PrivateRoute'; 2 | -------------------------------------------------------------------------------- /projement/app/src/core/testSetup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /projement/app/src/core/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default as AssignmentPage } from './AssignmentPage'; 2 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/DashboardPage/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './DashboardPage'; 2 | -------------------------------------------------------------------------------- /projement/static/styles-src/main.scss: -------------------------------------------------------------------------------- 1 | .assignment-content li { 2 | margin-bottom: 0.3rem; 3 | } 4 | -------------------------------------------------------------------------------- /projement/app/src/projects/forms/EditProjectForm/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './EditProjectForm'; 2 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/EditProjectPage/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './EditProjectPage'; 2 | -------------------------------------------------------------------------------- /projement/app/src/projects/forms/index.js: -------------------------------------------------------------------------------- 1 | export { default as EditProjectForm } from './EditProjectForm'; 2 | -------------------------------------------------------------------------------- /projement/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /projement/auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthConfig(AppConfig): 5 | name = "auth" 6 | -------------------------------------------------------------------------------- /projement/projects/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProjectsConfig(AppConfig): 5 | name = "projects" 6 | -------------------------------------------------------------------------------- /projement/static/styles-src/main.js: -------------------------------------------------------------------------------- 1 | // Include vendor styles 2 | import './vendor'; 3 | 4 | // Include main styles from scss 5 | import './main.scss'; 6 | -------------------------------------------------------------------------------- /projement/projement/rest/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | 4 | urlpatterns = [ 5 | path("", include("projects.rest.urls")), 6 | ] 7 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default as DashboardPage } from './DashboardPage'; 2 | export { default as EditProjectPage } from './EditProjectPage'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .idea 3 | *.py[cod] 4 | **/__pycache__ 5 | coverage/ 6 | 7 | .data/ 8 | node_modules/ 9 | projement/app/webpack-stats.json 10 | projement/app/build/ 11 | projement/assets/ 12 | -------------------------------------------------------------------------------- /projement/setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | DJANGO_SETTINGS_MODULE=projement.settings 3 | python_files=test_*.py tests/*.py tests.py 4 | norecursedirs=venv app node_modules 5 | 6 | [coverage:html] 7 | directory = coverage_html 8 | -------------------------------------------------------------------------------- /projement/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | exclude = ''' 3 | ( 4 | /( 5 | node_modules 6 | | app 7 | | coverage 8 | | coverage_html 9 | )/ 10 | | migrations 11 | ) 12 | ''' 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | venv/ 4 | 5 | .git 6 | 7 | *.py[cod] 8 | **/__pycache__ 9 | **/.cache 10 | **/.pytest_cache 11 | **/.coverage 12 | **/coverage 13 | **/cover 14 | 15 | .data*/ 16 | 17 | projement/node_modules/ 18 | -------------------------------------------------------------------------------- /projement/auth/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import views as django_auth_views 2 | 3 | from auth.forms import LoginForm 4 | 5 | 6 | class LoginView(django_auth_views.LoginView): 7 | authentication_form = LoginForm 8 | redirect_authenticated_user = True 9 | -------------------------------------------------------------------------------- /projement/templates/app.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | {% load render_bundle from webpack_loader %} 5 | 6 | {% block content %} 7 |
8 | 11 | {% endblock content %} 12 | -------------------------------------------------------------------------------- /projement/app/src/core/index.js: -------------------------------------------------------------------------------- 1 | export { default as App } from './App'; 2 | export { default as Navbar } from './Navbar'; 3 | export { default as PrivateRoute } from './PrivateRoute'; 4 | export { default as RefreshPage } from './RefreshPage'; 5 | export { default as Spinner } from './Spinner'; 6 | -------------------------------------------------------------------------------- /projement/app/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import 'bootswatch/dist/flatly/bootstrap.min.css'; 4 | 5 | import { App } from './core'; 6 | 7 | export const init = () => { 8 | ReactDOM.render(, document.querySelector('#app')); 9 | }; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /projement/auth/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.contrib.auth import views as django_auth_views 3 | 4 | from auth.views import LoginView 5 | 6 | urlpatterns = [ 7 | path("login/", LoginView.as_view(), name="login"), 8 | path("logout/", django_auth_views.logout_then_login, name="logout"), 9 | ] 10 | -------------------------------------------------------------------------------- /projement/projects/rest/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from rest_framework import routers 4 | 5 | from projects.rest.views import ProjectViewSet 6 | 7 | 8 | router = routers.DefaultRouter() 9 | router.register(r"projects", ProjectViewSet) 10 | 11 | urlpatterns = [ 12 | path("", include(router.urls)), 13 | ] 14 | -------------------------------------------------------------------------------- /projement/requirements.txt: -------------------------------------------------------------------------------- 1 | django==4.2.2 2 | django-crispy-forms==1.14.0 3 | markdown==3.4.3 4 | django-settings-export==1.2.1 5 | django-webpack-loader===1.4.1 6 | djangorestframework==3.14.0 7 | # Test & quality tools 8 | pytest==7.3.1 9 | pytest-django==4.5.2 10 | coverage==7.2.7 11 | black==23.3.0 12 | django-debug-toolbar==4.1.0 13 | Faker==18.10.1 14 | -------------------------------------------------------------------------------- /projement/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", {"modules": false}], "@babel/react"], 3 | "plugins": [], 4 | "env": { 5 | "test": { 6 | "presets": [ 7 | ["@babel/preset-env", { 8 | "targets": { 9 | "node": "current" 10 | } 11 | }], 12 | "@babel/react" 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /projement/app/src/core/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Spinner as BaseSpinner } from 'reactstrap'; 3 | 4 | const Spinner = () => ( 5 |
9 | 10 |
11 | ); 12 | 13 | export default Spinner; 14 | -------------------------------------------------------------------------------- /projement/projects/rest/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, permissions 2 | 3 | from projects.models import Project 4 | from projects.rest.serializers import ProjectSerializer 5 | 6 | 7 | class ProjectViewSet(viewsets.ModelViewSet): 8 | queryset = Project.objects.order_by("-start_date") 9 | serializer_class = ProjectSerializer 10 | permission_classes = [permissions.IsAuthenticated] 11 | -------------------------------------------------------------------------------- /projement/auth/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.forms import AuthenticationForm 2 | 3 | from crispy_forms.helper import FormHelper 4 | from crispy_forms.layout import Submit 5 | 6 | 7 | class LoginForm(AuthenticationForm): 8 | def __init__(self, request=None, *args, **kwargs): 9 | super().__init__(request, *args, **kwargs) 10 | self.helper = FormHelper() 11 | self.helper.add_input(Submit("submit", "SIGN IN")) 12 | -------------------------------------------------------------------------------- /projement/app/src/core/RefreshPage.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * Refresh the page if this component is rendered. 5 | * 6 | * Bit if a hack, but allows us to declaratively refresh the page after a React 7 | * Router redirect, for example. 8 | */ 9 | const RefreshPage = () => { 10 | useEffect(() => { 11 | window.location.reload(); 12 | }); 13 | 14 | return null; 15 | }; 16 | 17 | export default RefreshPage; 18 | -------------------------------------------------------------------------------- /projement/templates/base-server-rendered.html: -------------------------------------------------------------------------------- 1 | {% extends 'app.html' %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 | {# This causes the main app to run, but also attach some extra content into the body of the app, ensure routing doesn't redirect or load some other page. #} 7 | {{ block.super }} 8 | 9 |
10 | {% block container-content %}{% endblock container-content %} 11 |
12 | 13 | {% endblock content %} 14 | -------------------------------------------------------------------------------- /projement/projement/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for projement project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "projement.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /projement/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base-server-rendered.html' %} 2 | 3 | {% load crispy_forms_tags %} 4 | 5 | {% block container-content %} 6 |
7 |
8 |
9 |
10 | {% crispy form %} 11 |
12 |
13 |
14 |
15 | {% endblock container-content %} 16 | -------------------------------------------------------------------------------- /docker/Dockerfile-django: -------------------------------------------------------------------------------- 1 | # Development Dockerfile for Django app 2 | 3 | FROM python:3.8-slim 4 | 5 | # Set the default directory where CMD will execute 6 | WORKDIR /app 7 | 8 | # Run Django's runserver by default 9 | CMD ["python", "manage.py", "runserver", "0.0.0.0:80"] 10 | 11 | # Install dependencies 12 | RUN apt-get update && apt-get install -y \ 13 | gcc \ 14 | musl-dev 15 | 16 | # Install dependencies from requirements file 17 | COPY projement/requirements.txt . 18 | RUN pip install -r requirements.txt 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | django: 5 | build: 6 | context: . 7 | dockerfile: docker/Dockerfile-django 8 | volumes: 9 | - "./projement:/app" 10 | environment: 11 | - PYTHONUNBUFFERED=0 12 | - PYTHONPYCACHEPREFIX=../__pycache__ 13 | ports: 14 | - "8000:8000" 15 | command: python manage.py runserver 0.0.0.0:8000 16 | 17 | node: 18 | build: 19 | context: . 20 | dockerfile: docker/Dockerfile-node 21 | volumes: 22 | - ".data/node_modules:/app/node_modules" 23 | - "./projement:/app" 24 | -------------------------------------------------------------------------------- /projement/app/src/core/Navbar/Navbar.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { renderWithContext } from 'core/testUtils'; 4 | import Navbar from './Navbar'; 5 | 6 | describe('Navbar', () => { 7 | it('shows a log in link if the user is not logged in', () => { 8 | const { getByText } = renderWithContext(); 9 | 10 | expect(getByText(/log in/i)).toBeInTheDocument(); 11 | }); 12 | 13 | it('shows a log out link if the user is logged in', () => { 14 | DJ_CONST.user = { email: 'test@dude.ee' }; 15 | 16 | const { getByText } = renderWithContext(); 17 | 18 | expect(getByText(/log out/i)).toBeInTheDocument(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /projement/app/src/core/PrivateRoute/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const PrivateRoute = ({ children, ...rest }) => ( 6 | 9 | DJ_CONST.user ? ( 10 | children 11 | ) : ( 12 | 18 | ) 19 | } 20 | /> 21 | ); 22 | 23 | PrivateRoute.propTypes = { 24 | children: PropTypes.node, 25 | }; 26 | 27 | export default PrivateRoute; 28 | -------------------------------------------------------------------------------- /projement/app/src/projects/testUtils.js: -------------------------------------------------------------------------------- 1 | export const getMockProject = ({ ...overrides } = {}) => ({ 2 | id: 1, 3 | title: 'Test Project', 4 | start_date: new Date().toString(), 5 | end_date: null, 6 | estimated_design: 10, 7 | actual_design: 1, 8 | estimated_development: 20, 9 | actual_development: 2, 10 | estimated_testing: 30, 11 | actual_testing: 3, 12 | company: { 13 | id: 1, 14 | name: 'Test Company', 15 | }, 16 | tags: [ 17 | { 18 | id: 1, 19 | name: 'Test Tag', 20 | color: 'primary', 21 | }, 22 | ], 23 | 24 | is_over_budget: false, 25 | has_ended: false, 26 | total_estimated_hours: 10, 27 | total_actual_hours: 5, 28 | ...overrides, 29 | }); 30 | -------------------------------------------------------------------------------- /projement/app/src/core/RefreshPageIfEmpty.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * Refresh the page if this component is rendered. 5 | * 6 | * Bit if a hack, but allows us to declaratively refresh the page after a React 7 | * Router redirect, for example. 8 | */ 9 | const RefreshPage = () => { 10 | useEffect(() => { 11 | // For some reason even on the client side, the page is empty for a split second. So we need to wait a bit. 12 | setTimeout(() => { 13 | if ( 14 | !document.body 15 | .querySelector('.ssr-container') 16 | ?.textContent.trim() 17 | ) { 18 | window.location.reload(); 19 | } 20 | }, 200); 21 | }); 22 | 23 | return null; 24 | }; 25 | 26 | export default RefreshPage; 27 | -------------------------------------------------------------------------------- /docker/Dockerfile-node: -------------------------------------------------------------------------------- 1 | # Based on Node 10.x LTS image 2 | FROM node:10.15.1-alpine 3 | 4 | # Set the default directory where CMD will execute 5 | WORKDIR /app 6 | 7 | # Set the default command to execute when creating a new container 8 | CMD ["/bin/bash", "-c", "npm install && npm run dev"] 9 | 10 | # Install system requirements 11 | RUN apk add --no-cache build-base python bash asciidoctor 12 | 13 | 14 | # Convert the readme to html so that it can be shown in the AssignmentPage 15 | COPY README.adoc / 16 | COPY projement/app/README.adoc /FE.adoc 17 | COPY projement/README.adoc /BE.adoc 18 | COPY /projement/app/src/doc.css /doc.css 19 | 20 | RUN asciidoctor -a stylesheet=/doc.css /README.adoc --out-file /README.html 21 | RUN asciidoctor -a stylesheet=/doc.css /FE.adoc --out-file /FE.html 22 | RUN asciidoctor -a stylesheet=/doc.css /BE.adoc --out-file /BE.html 23 | -------------------------------------------------------------------------------- /projement/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:prettier/recommended", 7 | "plugin:jest/recommended" 8 | ], 9 | "plugins": [ 10 | "jest", 11 | "react-hooks" 12 | ], 13 | "env": { 14 | "browser": true, 15 | "jest/globals": true 16 | }, 17 | "globals": { 18 | "DJ_CONST": false 19 | }, 20 | "settings": { 21 | "import/resolver": { 22 | "webpack": { 23 | "config": "app/webpack/config.dev.js" 24 | } 25 | }, 26 | "react": { 27 | "version": "detect" 28 | } 29 | }, 30 | "rules": { 31 | "react-hooks/rules-of-hooks": "error", 32 | "react-hooks/exhaustive-deps": "warn" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /projement/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "projement.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /projement/app/webpack/set-public-path.js: -------------------------------------------------------------------------------- 1 | /** 2 | * In development mode, Webpack loads styles via JS, adding them to page as element. 3 | * 4 | * This will break loading of external resources such as fonts or images. The reason is that Webpack uses blob: url for 5 | * the style content and external resources must be accessed by full url there (including hostname). 6 | * 7 | * We could add static hostname (e.g. localhost:8000) to output.publicPath in Webpack config, but that would make it 8 | * harder to use other hostnames or ports in development mode. So instead, we define the resource path at runtime, using 9 | * Django's SITE_URL setting. 10 | * 11 | * Also note that this file must be specified separately for each Webpack entry point, requiring it from other files 12 | * will not work. 13 | */ 14 | 15 | // eslint-disable-next-line 16 | __webpack_public_path__ = DJ_CONST.SITE_URL + DJ_CONST.STATIC_URL; 17 | -------------------------------------------------------------------------------- /projement/app/webpack/config.dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const path = require('path'); 3 | 4 | const makeConfig = require('./config.base'); 5 | 6 | 7 | // The app/ dir 8 | const app_root = path.resolve(__dirname, '..'); 9 | const filenameTemplate = 'app/[name]'; 10 | 11 | 12 | const config = makeConfig({ 13 | filenameTemplate: filenameTemplate, 14 | 15 | mode: 'development', 16 | 17 | devtool: 'eval-source-map', 18 | 19 | namedModules: true, 20 | minimize: false, 21 | 22 | // Needed for inline CSS (via JS) - see set-public-path.js for more info 23 | prependSources: [path.resolve(app_root, 'webpack', 'set-public-path.js')], 24 | 25 | // This must be same as Django's STATIC_URL setting 26 | publicPath: '/static/', 27 | 28 | plugins: [], 29 | 30 | performance: { 31 | hints: 'warning', 32 | }, 33 | }); 34 | console.log("Using DEV config"); 35 | 36 | 37 | module.exports = config; 38 | -------------------------------------------------------------------------------- /projement/app/src/projects/propTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export const companyType = PropTypes.shape({ 4 | id: PropTypes.number.isRequired, 5 | name: PropTypes.string.isRequired, 6 | }); 7 | 8 | export const projectType = PropTypes.shape({ 9 | id: PropTypes.number.isRequired, 10 | company: companyType.isRequired, 11 | title: PropTypes.string.isRequired, 12 | start_date: PropTypes.string.isRequired, // YYYY-MM-DD format 13 | end_date: PropTypes.string, 14 | estimated_design: PropTypes.number.isRequired, 15 | actual_design: PropTypes.number.isRequired, 16 | estimated_development: PropTypes.number.isRequired, 17 | actual_development: PropTypes.number.isRequired, 18 | estimated_testing: PropTypes.number.isRequired, 19 | actual_testing: PropTypes.number.isRequired, 20 | 21 | // Django model properties 22 | total_estimated_hours: PropTypes.number.isRequired, 23 | total_actual_hours: PropTypes.number.isRequired, 24 | has_ended: PropTypes.bool.isRequired, 25 | is_over_budget: PropTypes.bool.isRequired, 26 | }); 27 | -------------------------------------------------------------------------------- /projement/projects/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from projects.models import Company, Project, Tag 4 | 5 | 6 | @admin.register(Project) 7 | class ProjectAdmin(admin.ModelAdmin): 8 | list_display = ("title", "company", "start_date", "end_date") 9 | list_filter = ("company__name",) 10 | ordering = ("-start_date",) 11 | 12 | fieldsets = ( 13 | (None, {"fields": ["company", "title", "start_date", "end_date"]}), 14 | ( 15 | "Estimated hours", 16 | { 17 | "fields": [ 18 | "estimated_design", 19 | "estimated_development", 20 | "estimated_testing", 21 | ] 22 | }, 23 | ), 24 | ( 25 | "Actual hours", 26 | {"fields": ["actual_design", "actual_development", "actual_testing"]}, 27 | ), 28 | ) 29 | 30 | def get_readonly_fields(self, request, obj=None): 31 | if obj is None: 32 | return () 33 | 34 | return ("company",) 35 | 36 | 37 | admin.site.register(Company) 38 | admin.site.register(Tag) 39 | -------------------------------------------------------------------------------- /projement/app/src/core/PrivateRoute/PrivateRoute.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { renderWithContext } from 'core/testUtils'; 4 | import PrivateRoute from './PrivateRoute'; 5 | 6 | describe('PrivateRoute', () => { 7 | it('renders the children if the user is logged in', () => { 8 | DJ_CONST.user = { id: 1 }; 9 | const { getByText, history } = renderWithContext( 10 | Hello, 11 | ); 12 | 13 | // The children are shown 14 | expect(getByText('Hello')).toBeInTheDocument(); 15 | // The user is not redirected 16 | expect(history.location.pathname).not.toBe('/login'); 17 | }); 18 | 19 | it('redirects the user if they are not logged in', async () => { 20 | DJ_CONST.user = null; 21 | const { history, queryByText } = renderWithContext( 22 | Hello, 23 | ); 24 | 25 | // The children are not shown 26 | expect(queryByText('Hello')).not.toBeInTheDocument(); 27 | // The user has been redirected to /login 28 | expect(history.location.pathname).toBe('/login'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /projement/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load render_bundle from webpack_loader %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | Projement 10 | 11 | 28 | 29 | {% render_bundle 'styles' %} 30 | {% render_bundle 'app' %} 31 | 32 | 33 | 34 | {% block content %}{% endblock content %} 35 | 36 | 37 | -------------------------------------------------------------------------------- /projement/app/src/core/store.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, createStore, applyMiddleware } from 'redux'; 2 | import { composeWithDevTools } from 'redux-devtools-extension'; 3 | import thunkMiddleware from 'redux-thunk'; 4 | 5 | import projectsReducer, { 6 | STATE_KEY as PROJECTS_KEY, 7 | } from 'projects/ducks/projects'; 8 | 9 | /** 10 | * Combine the reducers from ducks to the Redux store. 11 | */ 12 | const rootReducer = combineReducers({ 13 | [PROJECTS_KEY]: projectsReducer, 14 | }); 15 | 16 | /** 17 | * Create & configure the Redux store. 18 | * @param {object} [initialState] 19 | * @param {import('redux').Reducer} [reducer] 20 | * Defaults to the root reducer including all ducks. This could be 21 | * overridden in tests, for example, to only test specific reducers. 22 | */ 23 | export const configureStore = ( 24 | initialState = undefined, 25 | reducer = rootReducer, 26 | ) => { 27 | const middlewares = [thunkMiddleware]; 28 | const middlewareEnhancer = applyMiddleware(...middlewares); 29 | 30 | const enhancers = [middlewareEnhancer]; 31 | const composedEnhancers = composeWithDevTools(...enhancers); 32 | 33 | const store = createStore(reducer, initialState, composedEnhancers); 34 | 35 | return store; 36 | }; 37 | -------------------------------------------------------------------------------- /projement/app/src/core/testUtils.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { Router } from 'react-router-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { createMemoryHistory } from 'history'; 6 | 7 | import { configureStore } from 'core/store'; 8 | 9 | /** 10 | * Helper function to render a component inside providers. Returns the React 11 | * Testing Library utils exactly like `render` does, but also adds some useful 12 | * things to the returned object. 13 | * 14 | * Wraps the component in the following providers: 15 | * - Redux provider - initial state and store can be overridden with params 16 | * - React Router provider with a new `memoryHistory` that can be used to set 17 | * the router state 18 | */ 19 | export const renderWithContext = ( 20 | ui, 21 | { 22 | initialState = undefined, 23 | store = configureStore(initialState), 24 | route = undefined, 25 | } = {}, 26 | ) => { 27 | const history = createMemoryHistory(); 28 | if (route) { 29 | history.push(route); 30 | } 31 | 32 | return { 33 | ...render( 34 | 35 | {ui} 36 | , 37 | ), 38 | store, 39 | history, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /projement/app/src/core/pages/AssignmentPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import projectReadme from '/README.html'; 4 | import backEndReadme from '/BE.html'; 5 | import frontEndReadme from '/FE.html'; 6 | import { useParams } from 'react-router-dom'; 7 | 8 | /** 9 | * A more convenient way to look at the assignment. 10 | */ 11 | const AssignmentPage = () => { 12 | const { doc } = useParams(); 13 | 14 | const readme = { 15 | Assignment: projectReadme, 16 | Backend: backEndReadme, 17 | Frontend: frontEndReadme, 18 | }[doc]; 19 | if (readme) { 20 | return ( 21 | <> 22 |
26 |
27 |

© Thorgate

28 | 29 | ); 30 | } else { 31 | return ( 32 |
33 | Woops, look like that link doesn't work. 34 |
35 | These pages are just for quick reference for the main 3 READMES. 36 |
37 | Github renders links to project files correctly. 38 |
39 |
40 | ); 41 | } 42 | }; 43 | 44 | export default AssignmentPage; 45 | -------------------------------------------------------------------------------- /projement/projement/urls.py: -------------------------------------------------------------------------------- 1 | """projement URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.urls import include, path, re_path 18 | from django.contrib import admin 19 | from django.views.generic import TemplateView 20 | 21 | urlpatterns = [ 22 | path("", include("auth.urls")), 23 | path("api/", include("projement.rest.urls")), 24 | path("api-auth/", include("rest_framework.urls")), 25 | path("admin/", admin.site.urls), 26 | re_path(r"^(?P.*)/$", TemplateView.as_view(template_name="app.html")), 27 | path("", TemplateView.as_view(template_name="app.html"), name="app"), 28 | ] 29 | 30 | if settings.DEBUG: 31 | import debug_toolbar 32 | 33 | urlpatterns = [ 34 | path("__debug__/", include(debug_toolbar.urls)), 35 | ] + urlpatterns 36 | -------------------------------------------------------------------------------- /projement/app/src/doc.css: -------------------------------------------------------------------------------- 1 | @import "https://fonts.googleapis.com/css?family=Open+Sans:300,300italic,400,400italic,600,600italic%7CNoto+Serif:400,400italic,700,700italic%7CDroid+Sans+Mono:400,700"; 2 | @import "https://cdn.jsdelivr.net/gh/asciidoctor/asciidoctor@2.0/data/stylesheets/asciidoctor-default.css"; 3 | 4 | blockquote { 5 | border-left: 4px solid #cccccc; 6 | margin-left: 2px; 7 | padding-left: 1em; 8 | } 9 | 10 | p { 11 | font-size: 16px; 12 | } 13 | 14 | pre { 15 | padding: 8px; 16 | background-color: #f2f2f2; 17 | } 18 | 19 | h1 { 20 | margin-bottom: 1rem; 21 | font-size: 2.5rem; 22 | } 23 | 24 | h2 { 25 | border-bottom: 2px solid #cccccc; 26 | margin-top: 1.5rem; 27 | padding-bottom: 0.5rem; 28 | margin-bottom: 1rem; 29 | font-size: 2rem; 30 | } 31 | 32 | h3 { 33 | font-size: 1.75rem; 34 | margin-bottom: 0.75rem; 35 | } 36 | 37 | h4 { 38 | padding: 8px; 39 | background-color: #18BC9C; 40 | color: white; 41 | font-weight: bold; 42 | font-size: 1.3rem; 43 | border-radius: 2px; 44 | margin-bottom: 1rem; 45 | margin-top: 1.5rem; 46 | } 47 | 48 | h5 { 49 | background-color: #f2f2f2; 50 | font-weight: bold; 51 | padding: 12px; 52 | margin: 1rem 0 1.5rem; 53 | font-size: 1.1rem; 54 | line-height: 1.5rem; 55 | } 56 | 57 | p + ul { 58 | margin-top: -0.4rem; 59 | } 60 | 61 | ul { 62 | padding-left: 18px; 63 | } 64 | 65 | li { 66 | font-size: 1rem; 67 | } 68 | 69 | -------------------------------------------------------------------------------- /projement/projects/rest/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from projects.models import Project, Company, Tag 4 | 5 | 6 | class TagSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Tag 9 | fields = ("id", "name", "color") 10 | 11 | 12 | class CompanySerializer(serializers.ModelSerializer): 13 | tags = TagSerializer(many=True, read_only=True) 14 | 15 | class Meta: 16 | model = Company 17 | fields = ("id", "name", "tags") 18 | 19 | 20 | class ProjectSerializer(serializers.ModelSerializer): 21 | company = CompanySerializer(many=False, read_only=True) 22 | tags = serializers.SerializerMethodField() 23 | 24 | class Meta: 25 | model = Project 26 | fields = ( 27 | "id", 28 | "company", 29 | "tags", 30 | "title", 31 | "start_date", 32 | "end_date", 33 | "estimated_design", 34 | "actual_design", 35 | "estimated_development", 36 | "actual_development", 37 | "estimated_testing", 38 | "actual_testing", 39 | "has_ended", 40 | "total_estimated_hours", 41 | "total_actual_hours", 42 | "is_over_budget", 43 | ) 44 | read_only_fields = ( 45 | "title", 46 | "start_date", 47 | "end_date", 48 | "estimated_design", 49 | "estimated_development", 50 | "estimated_testing", 51 | ) 52 | 53 | def get_tags(self, obj: Project): 54 | return TagSerializer(obj.company.tags, many=True).data 55 | -------------------------------------------------------------------------------- /projement/projects/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework import status 6 | from rest_framework.test import APITestCase 7 | 8 | from projects.models import Project 9 | 10 | 11 | class ProjectsTestCase(TestCase): 12 | fixtures = ["projects/fixtures/initial.json"] 13 | 14 | def setUp(self): 15 | super().setUp() 16 | 17 | self.projects = Project.objects.order_by("id") 18 | 19 | def test_project_has_ended(self): 20 | # 2 of the projects have ended 21 | self.assertListEqual( 22 | [p.has_ended for p in self.projects], [True, True, False, True, False] 23 | ) 24 | 25 | def test_project_is_over_budget(self): 26 | # 1 of the projects is over budget 27 | self.assertListEqual( 28 | [p.is_over_budget for p in self.projects], [True, False, False, True, False] 29 | ) 30 | 31 | def test_total_estimated_hours(self): 32 | self.assertListEqual( 33 | [p.total_estimated_hours for p in self.projects], [690, 170, 40, 141, 1025] 34 | ) 35 | 36 | def test_total_actual_hours(self): 37 | self.assertListEqual( 38 | [p.total_actual_hours for p in self.projects], [739, 60, 5, 207, 1020] 39 | ) 40 | 41 | 42 | class ProjectsViewSetTestCase(APITestCase): 43 | fixtures = ["projects/fixtures/initial.json"] 44 | 45 | def setUp(self): 46 | super().setUp() 47 | self.user = User.objects.create_user(username="test", password="123") 48 | login = self.client.login(username="test", password="123") 49 | 50 | def test_projects_can_be_retrieved(self): 51 | url = reverse("project-list") 52 | response = self.client.get(url) 53 | 54 | self.assertEqual(response.status_code, status.HTTP_200_OK) 55 | # There are 3 projects in the response (loaded from the fixtures) 56 | self.assertEqual(len(response.data), 5) 57 | 58 | def test_projects_list_requires_authentication(self): 59 | self.client.logout() 60 | 61 | url = reverse("project-list") 62 | response = self.client.get(url) 63 | 64 | self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 65 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/EditProjectPage/EditProjectPage.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useEffect } from 'react'; 3 | import { useParams, useHistory } from 'react-router-dom'; 4 | import { Card, CardBody } from 'reactstrap'; 5 | import { connect } from 'react-redux'; 6 | 7 | import { EditProjectForm } from 'projects/forms'; 8 | import { 9 | getProjects, 10 | fetchProjects, 11 | updateProject, 12 | } from 'projects/ducks/projects'; 13 | import { projectType } from 'projects/propTypes'; 14 | import { Spinner } from 'core'; 15 | 16 | const EditProjectPage = ({ projects, fetchProjects, updateProject }) => { 17 | const { id } = useParams(); 18 | const history = useHistory(); 19 | useEffect(() => { 20 | fetchProjects(); 21 | }, [fetchProjects]); 22 | 23 | if (!projects.length) { 24 | return ; 25 | } 26 | 27 | const project = projects.find(project => project.id == id); 28 | 29 | return ( 30 | 31 | 32 | { 35 | try { 36 | await updateProject(id, values); 37 | } catch (errors) { 38 | return Object.entries(errors).forEach( 39 | ([field, error]) => { 40 | formikHelpers.setFieldError( 41 | field, 42 | error[0], 43 | ); 44 | }, 45 | ); 46 | } 47 | 48 | formikHelpers.setSubmitting(false); 49 | 50 | history.push('/dashboard'); 51 | }} 52 | /> 53 | 54 | 55 | ); 56 | }; 57 | 58 | EditProjectPage.propTypes = { 59 | projects: PropTypes.arrayOf(projectType).isRequired, 60 | fetchProjects: PropTypes.func.isRequired, 61 | updateProject: PropTypes.func.isRequired, 62 | }; 63 | 64 | const mapStateToProps = state => ({ 65 | projects: getProjects(state), 66 | }); 67 | 68 | const mapDispatchToProps = dispatch => ({ 69 | fetchProjects: () => dispatch(fetchProjects()), 70 | updateProject: (id, project) => dispatch(updateProject(id, project)), 71 | }); 72 | 73 | export default connect(mapStateToProps, mapDispatchToProps)(EditProjectPage); 74 | -------------------------------------------------------------------------------- /projement/app/src/core/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from 'reactstrap'; 3 | import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'; 4 | import { Provider } from 'react-redux'; 5 | 6 | import { DashboardPage, EditProjectPage } from 'projects/pages'; 7 | import { configureStore } from 'core/store'; 8 | import { PrivateRoute } from 'core'; 9 | import { AssignmentPage } from './pages'; 10 | import Navbar from './Navbar'; 11 | import RefreshPageIfEmpty from './RefreshPageIfEmpty'; 12 | 13 | const store = configureStore(); 14 | 15 | const App = () => ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {/** 29 | * The Login page content is loaded on the server side, 30 | * however the navbar & app are all loaded on the client side. 31 | * So we need to have a route, but we don't want to render anything else. 32 | */} 33 | 34 | 35 | 36 | 37 | 38 | 45 | {/** 46 | * This and the following route + redirect are so the links to and from the docs work, 47 | * even though the paths are wrong 48 | */} 49 | 50 | 51 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | 67 | export default App; 68 | -------------------------------------------------------------------------------- /projement/projement/management/commands/loadmanyprojects.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from random import choices, choice, randint 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from faker import Faker 7 | 8 | from projects.models import Company, Project, Tag 9 | 10 | fake = Faker() 11 | 12 | 13 | class Command(BaseCommand): 14 | help = "Helper for creating many projects so that performance issues could be tested more easily." 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument( 18 | "--nr-of-projects", 19 | type=int, 20 | default=300, 21 | dest="nr_of_projects", 22 | help="Number of projects to create", 23 | ) 24 | 25 | def handle(self, *args, **options): 26 | """ 27 | Create a bunch of projects. 28 | 29 | Creates a company for every 10 projects. 30 | """ 31 | nr_of_projects = options.get("nr_of_projects") 32 | NR_OF_TAGS = 10 33 | 34 | # Create tags until we have a good amount of tags to choose from 35 | tags: List[Tag] = list(Tag.objects.all()) 36 | while len(tags) < NR_OF_TAGS: 37 | color = choice(Tag.COLOR_CHOICES) 38 | tags.append(Tag.objects.create(name=fake.currency_code(), color=color[0])) 39 | tag_ids = [tag.id for tag in tags] 40 | 41 | # Create a few dummy companies for the projects 42 | companies: List[Company] = [] 43 | for i in range(round(nr_of_projects / 10)): 44 | company: Company = Company.objects.create(name=fake.company()) 45 | company.tags.add(*choices(tag_ids, k=randint(1, 3))) 46 | companies.append(company) 47 | 48 | # Create the given number of projects 49 | for i in range(nr_of_projects): 50 | start_date = fake.date_between(start_date="-20y", end_date="-1d") 51 | has_end_date = fake.boolean(chance_of_getting_true=50) 52 | end_date = ( 53 | fake.date_between(start_date, end_date="today") 54 | if has_end_date 55 | else None 56 | ) 57 | 58 | project: Project = Project.objects.create( 59 | company=choice(companies), 60 | title=fake.domain_name(), 61 | start_date=start_date, 62 | end_date=end_date, 63 | estimated_design=fake.pyint(min_value=1, max_value=999, step=1), 64 | actual_design=fake.pyint(min_value=1, max_value=999, step=1), 65 | estimated_development=fake.pyint(min_value=1, max_value=999, step=1), 66 | actual_development=fake.pyint(min_value=1, max_value=999, step=1), 67 | estimated_testing=fake.pyint(min_value=1, max_value=999, step=1), 68 | actual_testing=fake.pyint(min_value=1, max_value=999, step=1), 69 | ) 70 | -------------------------------------------------------------------------------- /projement/app/src/projects/forms/EditProjectForm/EditProjectForm.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent, wait } from '@testing-library/react'; 3 | 4 | import { getMockProject } from 'projects/testUtils'; 5 | import EditProjectForm from './EditProjectForm'; 6 | 7 | describe('EditProjectForm', () => { 8 | /** 9 | * Helper function to render the form and return some useful utilities (for 10 | * example, for filling or submitting the form). 11 | */ 12 | const renderForm = (initialData = {}) => { 13 | const mockOnSubmit = jest.fn(); 14 | const rtlUtils = render( 15 | , 19 | ); 20 | 21 | const fillField = (label, value) => { 22 | fireEvent.change(rtlUtils.getByLabelText(label), { 23 | target: { value }, 24 | }); 25 | }; 26 | 27 | const submit = () => fireEvent.click(rtlUtils.getByText(/update/i)); 28 | 29 | return { 30 | ...rtlUtils, 31 | mockOnSubmit, 32 | fillField, 33 | submit, 34 | }; 35 | }; 36 | 37 | it('allows the form to be submitted', async () => { 38 | const { fillField, submit, mockOnSubmit } = renderForm(); 39 | 40 | fillField(/actual design hours/i, 4); 41 | fillField(/actual development hours/i, 5); 42 | fillField(/actual testing hours/i, 6); 43 | submit(); 44 | 45 | await wait(() => { 46 | expect(mockOnSubmit).toHaveBeenCalled(); 47 | }); 48 | 49 | expect(mockOnSubmit.mock.calls[0][0]).toEqual({ 50 | actual_design: 4, 51 | actual_development: 5, 52 | actual_testing: 6, 53 | }); 54 | }); 55 | 56 | it('requires actual design hours to be set', async () => { 57 | const { mockOnSubmit, submit, getByText, fillField } = renderForm(); 58 | 59 | fillField(/actual design hours/i, ''); 60 | submit(); 61 | await wait(); 62 | 63 | expect(mockOnSubmit).not.toHaveBeenCalled(); 64 | expect( 65 | getByText(/actual design hours is a required field/i), 66 | ).toBeInTheDocument(); 67 | }); 68 | 69 | it('requires actual development hours to be set', async () => { 70 | const { mockOnSubmit, submit, fillField } = renderForm(); 71 | 72 | fillField(/actual development hours/i, ''); 73 | submit(); 74 | await wait(); 75 | 76 | expect(mockOnSubmit).not.toHaveBeenCalled(); 77 | }); 78 | 79 | it('requires actual testing hours to be set', async () => { 80 | const { mockOnSubmit, submit, fillField } = renderForm(); 81 | 82 | fillField(/actual testing hours/i, ''); 83 | submit(); 84 | await wait(); 85 | 86 | expect(mockOnSubmit).not.toHaveBeenCalled(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /projement/README.adoc: -------------------------------------------------------------------------------- 1 | :toc: 2 | = Projement back-end 3 | 4 | The back-end consists of a Docker container with _Python 3.8_ and _Django 2.2_. 5 | 6 | All Python package requirements are listed in 7 | link:../requirements.txt[`requirements.txt`]. The initial requirements include: 8 | 9 | * https://docs.djangoproject.com/en/2.2/[Django] as the base framework 10 | * http://django-crispy-forms.readthedocs.io/en/latest/[django-crispy-forms] 11 | for easier form layouts for server-rendered forms 12 | * http://pythonhosted.org/Markdown/siteindex.html[markdown] for rendering 13 | markdown in HTML 14 | * https://www.django-rest-framework.org/[djangorestframework] for creating the 15 | API for the front-end to consume 16 | * https://docs.pytest.org[pytest] for writing tests 17 | * https://github.com/psf/black[black] for linting and automatically formatting 18 | the code 19 | * https://django-debug-toolbar.readthedocs.io/en/latest/index.html[Django Debug 20 | Toolbar] to 21 | help with debugging 22 | 23 | If you'd like to install additional dependencies, add them to the 24 | link:../requirements.txt[`requirements.txt`] file and rebuild the Docker containers 25 | with `make build`. 26 | 27 | The application uses the _SQLite_ database back-end by default. 28 | 29 | == Apps 30 | 31 | There are two Django `apps` in this application: 32 | 33 | * link:auth[`auth`] app - Authentication concerns like logging the user in through 34 | the login form. 35 | * link:projects[`projects`] app - The core business logic of the application – 36 | keeping track of time spent on projects. Exposes a REST API that can be 37 | consumed by the front-end. Everything related to the API is in the 38 | link:projects/rest[`projects/rest/`] folder. This includes very standard Django 39 | REST Framework viewsets in link:projects/rest/views.py[`rest/views.py`], 40 | serializers in link:projects/rest/serializers.py[`rest/serializers.py`], and 41 | registering the routes from those viewsets in 42 | link:projects/rest/urls.py[`rest/urls.py`]. 43 | 44 | There are also some tests in the link:auth/tests.py[`auth/tests.py`] and 45 | link:projects/tests[`projects/tests`] folders and files. 46 | 47 | == Linting 48 | 49 | https://github.com/psf/black[Black] is used to automatically format and lint 50 | files. The `make lint-django` command checks that the project is correctly 51 | formatted. 52 | 53 | You can set up your editor to automatically format Python files using Black 54 | following the instructions https://github.com/psf/black#editor-integration[on Black's GitHub 55 | page]. 56 | 57 | You can also use the `make lint-django-fix` command to automatically format all 58 | Python files in the project. 59 | 60 | == Front-end 61 | 62 | The front-end is a React app that's rendered through Django. This means that 63 | Django renders a template (link:templates/app.html[`app.html`]) and inside that 64 | template, `ReactDOM.render` is manually called (`projement.init()` calls the 65 | `init` function in link:app/src/main.js[`app/src/main.js`]). 66 | 67 | Read more about how the front-end works in the link:app/README.adoc[front-end 68 | `README.adoc`]. 69 | -------------------------------------------------------------------------------- /projement/projects/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.7 on 2019-12-03 13:15 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Company', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=128)), 21 | ], 22 | options={ 23 | 'verbose_name_plural': 'companies', 24 | }, 25 | ), 26 | migrations.CreateModel( 27 | name='Tag', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('name', models.CharField(max_length=255, verbose_name='Name')), 31 | ('color', models.CharField(choices=[('primary', 'Primary'), ('secondary', 'Secondary'), ('success', 'Success'), ('danger', 'Danger'), ('warning', 'Warning'), ('info', 'Info'), ('light', 'Light'), ('dark', 'Dark')], max_length=128, verbose_name='Color')), 32 | ], 33 | ), 34 | migrations.CreateModel( 35 | name='Project', 36 | fields=[ 37 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 38 | ('title', models.CharField(max_length=128, verbose_name='Project title')), 39 | ('start_date', models.DateField(blank=True, null=True, verbose_name='Project start date')), 40 | ('end_date', models.DateField(blank=True, null=True, verbose_name='Project end date')), 41 | ('estimated_design', models.PositiveSmallIntegerField(verbose_name='Estimated design hours')), 42 | ('actual_design', models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Actual design hours')), 43 | ('estimated_development', models.PositiveSmallIntegerField(verbose_name='Estimated development hours')), 44 | ('actual_development', models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Actual development hours')), 45 | ('estimated_testing', models.PositiveSmallIntegerField(verbose_name='Estimated testing hours')), 46 | ('actual_testing', models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Actual testing hours')), 47 | ('company', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='projects.Company')), 48 | ], 49 | ), 50 | migrations.AddField( 51 | model_name='company', 52 | name='tags', 53 | field=models.ManyToManyField(related_name='projects', to='projects.Tag'), 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/DashboardPage/DashboardPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import fetchMock from 'fetch-mock'; 3 | import { waitForElement } from '@testing-library/react'; 4 | 5 | import { renderWithContext } from 'core/testUtils'; 6 | import { getMockProject } from 'projects/testUtils'; 7 | import DashboardPage from './DashboardPage'; 8 | 9 | describe('DashboardPage', () => { 10 | beforeEach(() => { 11 | fetchMock.reset(); 12 | }); 13 | 14 | it('fetches and renders a list of projects', async () => { 15 | // Mock the API request to return a specific list of projects 16 | fetchMock.getOnce('/api/projects', [ 17 | getMockProject({ 18 | total_estimated_hours: 10, 19 | total_actual_hours: 5, 20 | }), 21 | ]); 22 | 23 | const { getByText, getByTestId } = renderWithContext(); 24 | 25 | // An API request should be made and the resulting project should be 26 | // rendered 27 | await waitForElement(() => getByText(/test project/i)); 28 | 29 | expect(getByTestId('project-company-name-0').textContent).toBe( 30 | 'Test Company', 31 | ); 32 | expect(getByTestId('project-estimated-hours-0').textContent).toBe('10'); 33 | expect(getByTestId('project-actual-hours-0').textContent).toBe('5'); 34 | }); 35 | 36 | it('strikes through ended projects', async () => { 37 | fetchMock.getOnce('/api/projects', [ 38 | getMockProject({ has_ended: true }), 39 | ]); 40 | 41 | const { getByText } = renderWithContext(); 42 | 43 | const projectNameElem = await waitForElement(() => 44 | getByText(/test project/i), 45 | ); 46 | 47 | expect(projectNameElem).toHaveStyle('text-decoration: line-through'); 48 | }); 49 | 50 | it('shows a warning badge when a project is over budget', async () => { 51 | fetchMock.getOnce('/api/projects', [ 52 | getMockProject({ is_over_budget: true }), 53 | ]); 54 | 55 | const { getByTestId } = renderWithContext(); 56 | 57 | const elem = await waitForElement(() => 58 | getByTestId(/over-budget-badge/i), 59 | ); 60 | 61 | expect(elem).toBeInTheDocument(); 62 | }); 63 | 64 | it('shows a list of tags related to the company', async () => { 65 | fetchMock.getOnce('/api/projects', [ 66 | getMockProject({ 67 | tags: [{ id: 1, name: 'Test Tag', color: 'primary' }], 68 | }), 69 | ]); 70 | 71 | const { getByText } = renderWithContext(); 72 | 73 | const elem = await waitForElement(() => getByText(/test tag/i)); 74 | 75 | expect(elem).toBeInTheDocument(); 76 | }); 77 | 78 | it('shows a loading spinner while the projects are loading', async () => { 79 | fetchMock.getOnce('/api/projects', [getMockProject()]); 80 | 81 | const { getByTestId } = renderWithContext(); 82 | 83 | expect(getByTestId('spinner')).toBeInTheDocument(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /projement/projects/fixtures/initial.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "projects.tag", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Development", 7 | "color": "secondary" 8 | } 9 | }, 10 | { 11 | "model": "projects.tag", 12 | "pk": 2, 13 | "fields": { 14 | "name": "Forestry", 15 | "color": "success" 16 | } 17 | }, 18 | { 19 | "model": "projects.tag", 20 | "pk": 3, 21 | "fields": { 22 | "name": "Industry 4.0", 23 | "color": "primary" 24 | } 25 | }, 26 | { 27 | "model": "projects.company", 28 | "pk": 1, 29 | "fields": { 30 | "name": "Thorgate", 31 | "tags": [ 32 | 1, 33 | 2, 34 | 3 35 | ] 36 | } 37 | }, 38 | { 39 | "model": "projects.company", 40 | "pk": 2, 41 | "fields": { 42 | "name": "Vestman", 43 | "tags": [ 44 | 2 45 | ] 46 | } 47 | }, 48 | { 49 | "model": "projects.company", 50 | "pk": 3, 51 | "fields": { 52 | "name": "Krah Pipes", 53 | "tags": [ 54 | 3 55 | ] 56 | } 57 | }, 58 | { 59 | "model": "projects.project", 60 | "pk": 1, 61 | "fields": { 62 | "company": 1, 63 | "title": "GateMe", 64 | "start_date": "2013-01-01", 65 | "end_date": "2015-12-31", 66 | "estimated_design": 240, 67 | "actual_design": 200, 68 | "estimated_development": 350, 69 | "actual_development": 450, 70 | "estimated_testing": 100, 71 | "actual_testing": 89 72 | } 73 | }, 74 | { 75 | "model": "projects.project", 76 | "pk": 2, 77 | "fields": { 78 | "company": 1, 79 | "title": "Comics", 80 | "start_date": "2016-01-01", 81 | "end_date": "2016-02-01", 82 | "estimated_design": 50, 83 | "actual_design": 55, 84 | "estimated_development": 120, 85 | "actual_development": 5, 86 | "estimated_testing": 0, 87 | "actual_testing": 0 88 | } 89 | }, 90 | { 91 | "model": "projects.project", 92 | "pk": 3, 93 | "fields": { 94 | "company": 1, 95 | "title": "Projement", 96 | "start_date": "2016-10-01", 97 | "end_date": null, 98 | "estimated_design": 10, 99 | "actual_design": 0, 100 | "estimated_development": 25, 101 | "actual_development": 5, 102 | "estimated_testing": 5, 103 | "actual_testing": 0 104 | } 105 | }, 106 | { 107 | "model": "projects.project", 108 | "pk": 4, 109 | "fields": { 110 | "company": 2, 111 | "title": "Vaheladu", 112 | "start_date": "2013-10-10", 113 | "end_date": "2019-12-03", 114 | "estimated_design": 130, 115 | "actual_design": 5, 116 | "estimated_development": 8, 117 | "actual_development": 200, 118 | "estimated_testing": 3, 119 | "actual_testing": 2 120 | } 121 | }, 122 | { 123 | "model": "projects.project", 124 | "pk": 5, 125 | "fields": { 126 | "company": 3, 127 | "title": "Krah", 128 | "start_date": "2016-08-10", 129 | "end_date": null, 130 | "estimated_design": 20, 131 | "actual_design": 15, 132 | "estimated_development": 1000, 133 | "actual_development": 999, 134 | "estimated_testing": 5, 135 | "actual_testing": 6 136 | } 137 | } 138 | ] 139 | -------------------------------------------------------------------------------- /projement/auth/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.forms import AuthenticationForm 2 | from django.contrib.auth.models import User 3 | from django.test import Client, TestCase 4 | 5 | 6 | class AuthenticationTestCase(TestCase): 7 | def setUp(self): 8 | super().setUp() 9 | 10 | self.username = "Thorgate" 11 | self.password = "thorgate123" 12 | 13 | self.authenticated_user = User.objects.create_user( 14 | username=self.username, 15 | email="info@throgate.eu", 16 | password=self.password, 17 | ) 18 | 19 | self.authenticated_client = Client() 20 | self.authenticated_client.login(username=self.username, password=self.password) 21 | 22 | def test_login_redirect(self): 23 | # Anonymous user should see the login form 24 | 25 | client = Client() 26 | response = client.get("/login/") 27 | self.assertEqual(response.status_code, 200) 28 | 29 | # Authenticated user should be redirected to the assignment page 30 | 31 | response = self.authenticated_client.get("/login/") 32 | self.assertRedirects(response, "/", 302, 200) 33 | 34 | def test_login_form(self): 35 | # There should be the login form in the context 36 | 37 | client = Client() 38 | response = client.get("/login/") 39 | form = response.context["form"] 40 | self.assertIsInstance(form, AuthenticationForm) 41 | 42 | # Both, username and password, are required fields 43 | 44 | client = Client() 45 | response = client.post("/login/", {}) 46 | self.assertEqual(response.status_code, 200) 47 | form = response.context["form"] 48 | self.assertListEqual(["This field is required."], form.errors["username"]) 49 | self.assertListEqual(["This field is required."], form.errors["password"]) 50 | 51 | # Authentication should fail with the wrong password 52 | 53 | client = Client() 54 | response = client.post( 55 | "/login/", 56 | {"username": self.username, "password": self.password + self.password}, 57 | ) 58 | self.assertEqual(response.status_code, 200) 59 | form = response.context["form"] 60 | self.assertListEqual( 61 | [ 62 | "Please enter a correct username and password. Note that both fields may be case-sensitive." 63 | ], 64 | form.errors["__all__"], 65 | ) 66 | 67 | # Login view should redirect to assignment page after successful authentication 68 | 69 | client = Client() 70 | response = client.post( 71 | "/login/", {"username": self.username, "password": self.password} 72 | ) 73 | self.assertRedirects(response, "/", 302, 200) 74 | 75 | def test_logout_view(self): 76 | # Authenticated user should get a redirect after logging out 77 | 78 | response = self.authenticated_client.get("/logout/") 79 | self.assertRedirects(response, "/login/", 302, 200) 80 | 81 | # The user should not get a redirect on login page after logging out 82 | 83 | response = self.authenticated_client.get("/login/") 84 | self.assertEqual(response.status_code, 200) 85 | -------------------------------------------------------------------------------- /projement/projects/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.core.validators import MinValueValidator 3 | from django.urls import reverse 4 | from django.utils import timezone 5 | from django.utils.text import slugify 6 | 7 | 8 | class Tag(models.Model): 9 | COLOR_CHOICES = ( 10 | ("primary", "Primary"), 11 | ("secondary", "Secondary"), 12 | ("success", "Success"), 13 | ("danger", "Danger"), 14 | ("warning", "Warning"), 15 | ("info", "Info"), 16 | ("light", "Light"), 17 | ("dark", "Dark"), 18 | ) 19 | 20 | name = models.CharField("Name", max_length=255) 21 | # For simplicity's sake, we only allow Bootstrap colors to be chosen 22 | color = models.CharField("Color", choices=COLOR_CHOICES, max_length=128) 23 | 24 | def __str__(self): 25 | return self.name 26 | 27 | 28 | class Company(models.Model): 29 | class Meta: 30 | verbose_name_plural = "companies" 31 | 32 | tags = models.ManyToManyField(Tag, related_name="projects") 33 | name = models.CharField(max_length=128) 34 | 35 | def __str__(self): 36 | return self.name 37 | 38 | 39 | class Project(models.Model): 40 | company = models.ForeignKey( 41 | "projects.Company", 42 | on_delete=models.PROTECT, 43 | related_name="projects", 44 | ) 45 | 46 | title = models.CharField("Project title", max_length=128) 47 | start_date = models.DateField("Project start date", blank=True, null=True) 48 | end_date = models.DateField("Project end date", blank=True, null=True) 49 | 50 | estimated_design = models.PositiveSmallIntegerField("Estimated design hours") 51 | actual_design = models.PositiveSmallIntegerField( 52 | "Actual design hours", 53 | default=0, 54 | validators=[MinValueValidator(0)], 55 | ) 56 | 57 | estimated_development = models.PositiveSmallIntegerField( 58 | "Estimated development hours" 59 | ) 60 | actual_development = models.PositiveSmallIntegerField( 61 | "Actual development hours", 62 | default=0, 63 | validators=[MinValueValidator(0)], 64 | ) 65 | 66 | estimated_testing = models.PositiveSmallIntegerField("Estimated testing hours") 67 | actual_testing = models.PositiveSmallIntegerField( 68 | "Actual testing hours", 69 | default=0, 70 | validators=[MinValueValidator(0)], 71 | ) 72 | 73 | def __str__(self): 74 | return self.title 75 | 76 | def get_absolute_url(self): 77 | return reverse( 78 | "project-update", kwargs={"pk": self.pk, "slug": slugify(self.title)} 79 | ) 80 | 81 | @property 82 | def has_ended(self): 83 | return self.end_date is not None and self.end_date < timezone.now().date() 84 | 85 | @property 86 | def total_estimated_hours(self): 87 | return ( 88 | self.estimated_design + self.estimated_development + self.estimated_testing 89 | ) 90 | 91 | @property 92 | def total_actual_hours(self): 93 | return self.actual_design + self.actual_development + self.actual_testing 94 | 95 | @property 96 | def is_over_budget(self): 97 | return self.total_actual_hours > self.total_estimated_hours 98 | -------------------------------------------------------------------------------- /projement/app/src/projects/ducks/projects.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | /** 4 | * State key for the projects' duck. This should be used to access the duck's 5 | * state whenever needed. For example, `state[STATE_KEY].projects`. 6 | */ 7 | export const STATE_KEY = 'projects'; 8 | 9 | // Actions 10 | 11 | const RECEIVE_PROJECTS = `${STATE_KEY}/RECEIVE_PROJECTS`; 12 | const RECEIVE_UPDATED_PROJECT = `${STATE_KEY}/RECEIVE_UPDATED_PROJECT`; 13 | 14 | const SET_LOADING = `${STATE_KEY}/SET_LOADING`; 15 | 16 | // Reducers 17 | 18 | const projectsReducer = (state = [], action) => { 19 | switch (action.type) { 20 | case RECEIVE_PROJECTS: 21 | return action.projects; 22 | case RECEIVE_UPDATED_PROJECT: 23 | return state.map(project => 24 | project.id === action.project.id ? action.project : project, 25 | ); 26 | 27 | default: 28 | return state; 29 | } 30 | }; 31 | 32 | const isLoadingReducer = (state = false, action) => { 33 | switch (action.type) { 34 | case SET_LOADING: 35 | return action.isLoading; 36 | case RECEIVE_PROJECTS: 37 | return false; 38 | 39 | default: 40 | return state; 41 | } 42 | }; 43 | 44 | export default combineReducers({ 45 | projects: projectsReducer, 46 | isLoading: isLoadingReducer, 47 | }); 48 | 49 | // Action creators 50 | 51 | const receiveProjects = projects => ({ 52 | type: RECEIVE_PROJECTS, 53 | projects, 54 | }); 55 | 56 | const receiveUpdatedProject = project => ({ 57 | type: RECEIVE_UPDATED_PROJECT, 58 | project, 59 | }); 60 | 61 | const setIsLoading = isLoading => ({ type: SET_LOADING, isLoading }); 62 | 63 | /** 64 | * Thunk to fetch the list of projects and save them to the store. 65 | */ 66 | export const fetchProjects = () => async dispatch => { 67 | dispatch(setIsLoading(true)); 68 | 69 | let response; 70 | try { 71 | response = await fetch('/api/projects').then(res => res.json()); 72 | } catch (e) { 73 | return console.error(e); 74 | } 75 | 76 | dispatch(receiveProjects(response)); 77 | 78 | return response; 79 | }; 80 | 81 | /** 82 | * Update the project with the given values. 83 | */ 84 | export const updateProject = (projectId, projectValues) => async dispatch => { 85 | let response; 86 | try { 87 | response = await fetch(`/api/projects/${projectId}/`, { 88 | method: 'PUT', 89 | body: JSON.stringify(projectValues), 90 | headers: { 91 | 'Content-Type': 'application/json', 92 | 'X-CSRFToken': DJ_CONST.csrfToken, 93 | }, 94 | }); 95 | } catch (e) { 96 | return console.error(e); 97 | } 98 | 99 | const json = await response.json(); 100 | if (response.status === 400) { 101 | // We got some validation errors 102 | throw json; 103 | } 104 | 105 | dispatch(receiveUpdatedProject(json)); 106 | 107 | return response; 108 | }; 109 | 110 | // Selectors 111 | 112 | export const getProjects = state => state[STATE_KEY].projects; 113 | export const getIsLoading = state => state[STATE_KEY].isLoading; 114 | -------------------------------------------------------------------------------- /projement/app/src/projects/forms/EditProjectForm/EditProjectForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Formik, Form, Field } from 'formik'; 4 | import { Button, Input, FormGroup, Label, FormFeedback } from 'reactstrap'; 5 | import * as Yup from 'yup'; 6 | 7 | import { projectType } from 'projects/propTypes'; 8 | 9 | /** 10 | * Custom form field component to make using Reactstrap and Formik together 11 | * easier and less verbose. 12 | */ 13 | const FormField = ({ label, name, touched, errors }) => ( 14 | 15 | 16 | 25 | {touched[name] && errors[name] && ( 26 | {errors[name]} 27 | )} 28 | 29 | ); 30 | 31 | FormField.propTypes = { 32 | label: PropTypes.string.isRequired, 33 | name: PropTypes.string.isRequired, 34 | touched: PropTypes.object.isRequired, 35 | errors: PropTypes.object.isRequired, 36 | }; 37 | 38 | /** 39 | * Form for editing the actual hours for a project. 40 | */ 41 | const EditProjectForm = ({ project, onSubmit }) => ( 42 | 64 | {({ touched, errors, isSubmitting }) => ( 65 |
66 | 72 | 78 | 84 | 87 | 88 | )} 89 |
90 | ); 91 | 92 | EditProjectForm.propTypes = { 93 | project: projectType.isRequired, 94 | onSubmit: PropTypes.func.isRequired, 95 | }; 96 | 97 | export default EditProjectForm; 98 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/EditProjectPage/EditProjectPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { wait, fireEvent } from '@testing-library/react'; 3 | import fetchMock from 'fetch-mock'; 4 | import { Route } from 'react-router-dom'; 5 | 6 | import { renderWithContext } from 'core/testUtils'; 7 | import { EditProjectPage } from 'projects/pages'; 8 | import { getMockProject } from 'projects/testUtils'; 9 | 10 | describe('EditProjectPage', () => { 11 | /** 12 | * Utility to render the page. This could return some helper function like 13 | * `fillForm` or `submitForm`. 14 | */ 15 | const renderPage = () => { 16 | const rtlUtils = renderWithContext( 17 | // Need the Route component here in order to get the URL param 18 | // matching to work 19 | , 20 | { route: '/projects/1' }, 21 | ); 22 | 23 | return { 24 | ...rtlUtils, 25 | }; 26 | }; 27 | 28 | beforeEach(() => { 29 | fetchMock.reset(); 30 | }); 31 | 32 | it('sends an API request to the server when the form is submitted', async () => { 33 | const NEW_ACTUAL_DESIGN = 10; 34 | 35 | // Mock the API request to fetch the projects 36 | fetchMock.getOnce('/api/projects', [getMockProject()]); 37 | // And the API request to update the given project with the new 38 | // actual_design value 39 | fetchMock.putOnce( 40 | '/api/projects/1/', 41 | getMockProject({ actual_design: NEW_ACTUAL_DESIGN }), 42 | ); 43 | 44 | const { 45 | history, 46 | getByLabelText, 47 | getByText, 48 | getByTestId, 49 | } = renderPage(); 50 | 51 | // There should be a loading indicator since we're fetching the 52 | // projects 53 | expect(getByTestId('spinner')).toBeInTheDocument(); 54 | await wait(); 55 | 56 | // Make a small change and submit the form 57 | fireEvent.change(getByLabelText(/actual design hours/i), { 58 | target: { value: NEW_ACTUAL_DESIGN }, 59 | }); 60 | fireEvent.click(getByText(/update/i)); 61 | await wait(); 62 | 63 | // Expect the correct parameters to have been sent to the server 64 | expect(fetchMock.calls()[1][1].body).toEqual( 65 | JSON.stringify({ 66 | actual_design: 10, 67 | actual_development: 2, 68 | actual_testing: 3, 69 | }), 70 | ); 71 | 72 | // Redirect to projects' list view after submitting 73 | expect(history.location.pathname).toBe('/dashboard'); 74 | }); 75 | 76 | it('shows validation errors from the server', async () => { 77 | fetchMock.getOnce('/api/projects', [getMockProject()]); 78 | // Mock the PUT request to return some validation errors in the DRF 79 | // format 80 | fetchMock.putOnce('/api/projects/1/', { 81 | status: 400, 82 | body: { actual_design: ['It is bad!'] }, 83 | }); 84 | 85 | const { getByText } = renderPage(); 86 | await wait(); 87 | 88 | fireEvent.click(getByText(/update/i)); 89 | await wait(); 90 | 91 | // Shows the validation error to the user 92 | expect(getByText(/it is bad!/i)).toBeInTheDocument(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /projement/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "projement", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "cd app && NODE_ENV=development webpack --config webpack/config.dev.js --watch --profile --color", 7 | "build": "cd app && NODE_ENV=development webpack --config webpack/config.dev.js --profile --color", 8 | "test": "NODE_ENV=test jest", 9 | "lint": "eslint app/src", 10 | "lint-fix": "eslint --fix app/src" 11 | }, 12 | "jest": { 13 | "globals": { 14 | "NODE_ENV": "test", 15 | "DJ_CONST": { 16 | "user": null, 17 | "csrfToken": "token" 18 | } 19 | }, 20 | "presets": [ 21 | "@babel/react", 22 | "@babel/env" 23 | ], 24 | "moduleFileExtensions": [ 25 | "js", 26 | "json" 27 | ], 28 | "modulePaths": [ 29 | "/app/src", 30 | "/node_modules" 31 | ], 32 | "setupFilesAfterEnv": [ 33 | "/app/src/core/testSetup.js" 34 | ], 35 | "transform": { 36 | "^.+\\.js?$": "babel-jest", 37 | "^.+\\.html?$": "html-loader-jest" 38 | }, 39 | "transformIgnorePatterns": [ 40 | "/node_modules/" 41 | ], 42 | "verbose": true, 43 | "collectCoverageFrom": [ 44 | "app/src/**/*.js" 45 | ] 46 | }, 47 | "dependencies": { 48 | "@babel/core": "7.13", 49 | "@babel/plugin-proposal-decorators": "7.13", 50 | "@babel/plugin-proposal-export-default-from": "7.12", 51 | "@babel/plugin-proposal-throw-expressions": "7.12", 52 | "@babel/plugin-transform-runtime": "7.13", 53 | "@babel/polyfill": "7.12", 54 | "@babel/preset-env": "7.13", 55 | "@babel/preset-react": "7.12", 56 | "@babel/runtime": "7.13", 57 | "@fortawesome/fontawesome-svg-core": "1.2.25", 58 | "@fortawesome/free-solid-svg-icons": "5.11.2", 59 | "@fortawesome/react-fontawesome": "0.1.7", 60 | "bootswatch": "4.3.1", 61 | "formik": "2.0.6", 62 | "prop-types": "15.7.2", 63 | "react": "16.12.0", 64 | "react-dom": "16.12.0", 65 | "react-redux": "7.1.3", 66 | "react-router-dom": "5.1.2", 67 | "reactstrap": "8.1.1", 68 | "redux": "4.0.4", 69 | "redux-devtools-extension": "2.13.8", 70 | "redux-thunk": "2.3.0", 71 | "yup": "0.27.0" 72 | }, 73 | "devDependencies": { 74 | "@testing-library/jest-dom": "4.2.4", 75 | "@testing-library/react": "9.3.2", 76 | "autoprefixer": "9.8", 77 | "babel-eslint": "10.0.3", 78 | "babel-jest": "24.9.0", 79 | "babel-loader": "8.2", 80 | "css-loader": "6.4.0", 81 | "eslint": "6.7.1", 82 | "eslint-config-prettier": "6.7.0", 83 | "eslint-import-resolver-webpack": "0.11.1", 84 | "eslint-plugin-import": "2.18.2", 85 | "eslint-plugin-jest": "23.0.5", 86 | "eslint-plugin-prettier": "3.1.1", 87 | "eslint-plugin-react": "7.16.0", 88 | "eslint-plugin-react-hooks": "2.3.0", 89 | "fetch-mock": "8.0.0", 90 | "html-loader": "^1.3.2", 91 | "html-loader-jest": "^0.2.1", 92 | "jest": "24.9.0", 93 | "mini-css-extract-plugin": "2.5", 94 | "node-fetch": "2.6.0", 95 | "postcss": "^8.4.24", 96 | "postcss-loader": "6.1", 97 | "prettier": "1.19.1", 98 | "raw-loader": "3.1.0", 99 | "resolve-url-loader": "4.0", 100 | "sass": "1.54.9", 101 | "sass-loader": "12.4.0", 102 | "url-loader": "4.1", 103 | "webpack": "5.68", 104 | "webpack-bundle-tracker": "1.4", 105 | "webpack-cli": "4.9" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /projement/app/src/core/Navbar/Navbar.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Navbar as BaseNavbar, 4 | Container, 5 | NavbarBrand, 6 | NavbarToggler, 7 | Collapse, 8 | Nav, 9 | NavItem, 10 | NavLink, 11 | DropdownToggle, 12 | DropdownMenu, 13 | DropdownItem, 14 | UncontrolledButtonDropdown, 15 | } from 'reactstrap'; 16 | import { NavLink as RRNavLink } from 'react-router-dom'; 17 | 18 | const Navbar = () => { 19 | const [isOpen, setIsOpen] = useState(false); 20 | const { user } = DJ_CONST; 21 | 22 | const toggle = () => setIsOpen(!isOpen); 23 | 24 | return ( 25 | 26 | 27 | 28 | Projement 29 | 30 | 31 | 32 | 44 | 89 | 90 | 91 | 92 | ); 93 | }; 94 | 95 | export default Navbar; 96 | -------------------------------------------------------------------------------- /projement/app/src/projects/pages/DashboardPage/DashboardPage.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useEffect } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Table, Badge } from 'reactstrap'; 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 6 | import { faClock } from '@fortawesome/free-solid-svg-icons'; 7 | import { Link } from 'react-router-dom'; 8 | 9 | import { Spinner } from 'core'; 10 | import { 11 | fetchProjects, 12 | getProjects, 13 | getIsLoading, 14 | } from 'projects/ducks/projects'; 15 | import { projectType } from 'projects/propTypes'; 16 | 17 | const DashboardPage = ({ fetchProjects, projects, isLoading }) => { 18 | useEffect(() => { 19 | fetchProjects(); 20 | }, [fetchProjects]); 21 | 22 | if (isLoading) { 23 | return ; 24 | } 25 | 26 | const calculateProjectHasEnded = project => { 27 | return new Date(project.start_date).getDay() === new Date().getDay(); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {projects.map((project, i) => ( 43 | 44 | 62 | 73 | 76 | 79 | 82 | 83 | ))} 84 | 85 |
ProjectTagsCompanyEstimatedActual
45 | 46 | {calculateProjectHasEnded(project) ? ( 47 | {project.title} 48 | ) : ( 49 | project.title 50 | )} 51 | 52 | {project.is_over_budget && ( 53 | 58 | 59 | 60 | )} 61 | 63 | {project.tags.map(tag => ( 64 | 69 | {tag.name} 70 | 71 | ))} 72 | 74 | {project.company.name} 75 | 77 | {project.total_estimated_hours} 78 | 80 | {project.total_actual_hours} 81 |
86 | ); 87 | }; 88 | 89 | DashboardPage.propTypes = { 90 | fetchProjects: PropTypes.func.isRequired, 91 | isLoading: PropTypes.bool.isRequired, 92 | projects: PropTypes.arrayOf(projectType).isRequired, 93 | }; 94 | 95 | const mapStateToProps = state => ({ 96 | projects: getProjects(state), 97 | isLoading: getIsLoading(state), 98 | }); 99 | 100 | const mapDispatchToProps = dispatch => ({ 101 | fetchProjects: () => dispatch(fetchProjects()), 102 | }); 103 | 104 | export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage); 105 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BLACK ?= \033[0;30m 2 | RED ?= \033[0;31m 3 | GREEN ?= \033[0;32m 4 | YELLOW ?= \033[0;33m 5 | BLUE ?= \033[0;34m 6 | PURPLE ?= \033[0;35m 7 | CYAN ?= \033[0;36m 8 | GRAY ?= \033[0;37m 9 | WHITE ?= \033[1;37m 10 | COFF ?= \033[0m 11 | 12 | .PHONY: all shell build build-node coverage-node coverage-django coverage docker help load_initial_data migrate quality setup superuser test-node test-django test install-npm-dependencies runserver makemigrations eslint eslint-fix lint-django lint-django-fix format 13 | 14 | all: help 15 | 16 | help: 17 | @echo -e "\n$(WHITE)Available commands:$(COFF)" 18 | @echo -e "$(CYAN)make setup$(COFF) - Sets up the project in your local machine." 19 | @echo -e " This includes building Docker containers and running migrations." 20 | @echo -e "$(CYAN)make runserver$(COFF) - Runs the django app in the container, available at http://127.0.0.1:8000" 21 | @echo -e "$(CYAN)make migrate$(COFF) - Runs django's migrate command in the container" 22 | @echo -e "$(CYAN)make makemigrations$(COFF) - Runs django's makemigrations command in the container" 23 | @echo -e "$(CYAN)make superuser$(COFF) - Runs django's createsuperuser command in the container" 24 | @echo -e "$(CYAN)make shell$(COFF) - Starts a Linux shell (bash) in the django container" 25 | @echo -e "$(CYAN)make test$(COFF) - Runs automatic tests on your python code" 26 | @echo -e "$(CYAN)make coverage$(COFF) - Runs code test coverage calculation" 27 | @echo -e "$(CYAN)make quality$(COFF) - Runs code quality tests on your code" 28 | @echo -e "$(CYAN)make format$(COFF) - Runs code formatters on your code" 29 | 30 | 31 | shell: 32 | @echo -e "$(CYAN)Starting Bash in the django container:$(COFF)" 33 | @docker-compose run --rm django bash 34 | 35 | build: 36 | @echo -e "$(CYAN)Creating Docker images:$(COFF)" 37 | @docker-compose build 38 | 39 | build-node: 40 | @echo -e "$(CYAN)Building JavaScript files:$(COFF)" 41 | @docker-compose run --rm node npm run build 42 | 43 | install-npm-dependencies: 44 | @echo -e "$(CYAN)Installing Node dependencies:$(COFF)" 45 | @docker-compose run --rm node npm install 46 | 47 | runserver: 48 | @echo -e "$(CYAN)Starting Docker container with the app.$(COFF)" 49 | @docker-compose up 50 | @echo -e "$(CYAN)App ready and listening at http://127.0.0.1:8000.$(COFF)" 51 | 52 | setup: build install-npm-dependencies build-node migrate 53 | @echo -e "$(GREEN)====================================================================" 54 | @echo "SETUP SUCCEEDED" 55 | @echo -e "Run 'make runserver' to start the Django development server and the node server.$(COFF)" 56 | 57 | test-node: 58 | @echo -e "$(CYAN)Running automatic node.js tests:$(COFF)" 59 | @docker-compose run --rm node npm run test $(cmd) 60 | 61 | test-django: 62 | @echo -e "$(CYAN)Running automatic django tests:$(COFF)" 63 | @docker-compose run --rm django py.test 64 | 65 | test: test-node test-django 66 | @echo -e "$(GREEN)All tests passed.$(COFF)" 67 | 68 | coverage-node: 69 | @echo -e "$(CYAN)Running automatic code coverage check for JavaScript:$(COFF)" 70 | @docker-compose run --rm node npm run test --coverage 71 | 72 | coverage-django: 73 | @echo -e "$(CYAN)Running automatic code coverage check for Python:$(COFF)" 74 | @docker-compose run --rm django sh -c "coverage run -m py.test && coverage html && coverage report" 75 | 76 | coverage: coverage-node coverage-django 77 | @echo -e "$(GREEN)Coverage reports generated:" 78 | @echo "- Python coverage: projement/coverage_html/" 79 | @echo -e "- JavaScript coverage: projement/coverage/$(COFF)" 80 | 81 | makemigrations: 82 | @echo -e "$(CYAN)Running django makemigrations:$(COFF)" 83 | @docker-compose run --rm django ./manage.py makemigrations $(cmd) 84 | 85 | migrate: 86 | @echo -e "$(CYAN)Running django migrations:$(COFF)" 87 | @docker-compose run --rm django ./manage.py migrate $(cmd) 88 | 89 | load_initial_data: 90 | @echo -e "$(CYAN)Loading django fixture:$(COFF)" 91 | @docker-compose run --rm django ./manage.py loaddata projects/fixtures/initial.json 92 | 93 | loadmanyprojects: 94 | @echo -e "$(CYAN)Loading lots of projects:$(COFF)" 95 | @docker-compose run --rm django ./manage.py loadmanyprojects $(cmd) 96 | 97 | superuser: 98 | @echo -e "$(CYAN)Creating Docker images:$(COFF)" 99 | @docker-compose run --rm django ./manage.py createsuperuser 100 | 101 | eslint: 102 | @echo -e "$(CYAN)Running ESLint:$(COFF)" 103 | @docker-compose run --rm node npm run lint 104 | 105 | eslint-fix: 106 | @echo -e "$(CYAN)Running ESLint fix:$(COFF)" 107 | @docker-compose run --rm node npm run lint-fix 108 | 109 | lint-django: 110 | @echo -e "$(CYAN)Running Black check:$(COFF)" 111 | @docker-compose run --rm django black --check . 112 | 113 | lint-django-fix: 114 | @echo -e "$(CYAN)Running Black formatting:$(COFF)" 115 | @docker-compose run --rm django black . 116 | 117 | quality: eslint lint-django 118 | @echo -e "$(GREEN)No code style issues detected.$(COFF)" 119 | 120 | format: eslint-fix lint-django-fix 121 | -------------------------------------------------------------------------------- /projement/projement/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for projement project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | SITE_ROOT = os.path.dirname(os.path.dirname(__file__)) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "pu10i_p%efuvr*cyys_f(g%4xlr1$c*-6dvl^!*@bsywku_b&b" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | INTERNAL_IPS = ["127.0.0.1"] 31 | 32 | STATICFILES_DIRS = ( 33 | os.path.join(SITE_ROOT, "static"), 34 | os.path.join(SITE_ROOT, "app", "build"), 35 | ) 36 | 37 | WEBPACK_LOADER = { 38 | "DEFAULT": { 39 | "BUNDLE_DIR_NAME": "", 40 | "STATS_FILE": os.path.join(SITE_ROOT, "app", "webpack-stats.json"), 41 | } 42 | } 43 | 44 | 45 | # Application definition 46 | 47 | INSTALLED_APPS = [ 48 | "projects", 49 | "projement", 50 | "crispy_forms", 51 | "webpack_loader", 52 | "rest_framework", 53 | "debug_toolbar", 54 | "django.contrib.admin", 55 | "django.contrib.auth", 56 | "django.contrib.contenttypes", 57 | "django.contrib.sessions", 58 | "django.contrib.messages", 59 | "django.contrib.staticfiles", 60 | ] 61 | 62 | MIDDLEWARE = [ 63 | "debug_toolbar.middleware.DebugToolbarMiddleware", 64 | "django.middleware.security.SecurityMiddleware", 65 | "django.contrib.sessions.middleware.SessionMiddleware", 66 | "django.middleware.common.CommonMiddleware", 67 | "django.middleware.csrf.CsrfViewMiddleware", 68 | "django.contrib.auth.middleware.AuthenticationMiddleware", 69 | "django.contrib.messages.middleware.MessageMiddleware", 70 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 71 | ] 72 | 73 | ROOT_URLCONF = "projement.urls" 74 | 75 | TEMPLATES = [ 76 | { 77 | "BACKEND": "django.template.backends.django.DjangoTemplates", 78 | "DIRS": ["templates"], 79 | "APP_DIRS": True, 80 | "OPTIONS": { 81 | "context_processors": [ 82 | "django.template.context_processors.debug", 83 | "django.template.context_processors.request", 84 | "django.contrib.auth.context_processors.auth", 85 | "django.contrib.messages.context_processors.messages", 86 | "django_settings_export.settings_export", 87 | ], 88 | }, 89 | }, 90 | ] 91 | 92 | WSGI_APPLICATION = "projement.wsgi.application" 93 | 94 | 95 | # Database 96 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 97 | 98 | DATABASES = { 99 | "default": { 100 | "ENGINE": "django.db.backends.sqlite3", 101 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 102 | } 103 | } 104 | 105 | 106 | # Password validation 107 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 108 | 109 | AUTH_PASSWORD_VALIDATORS = [ 110 | { 111 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 121 | }, 122 | ] 123 | 124 | 125 | # Static site url, used when we need absolute url but lack request object, e.g. in email sending. 126 | SITE_URL = "http://127.0.0.1:8000" 127 | 128 | # Authentication URLs 129 | # https://docs.djangoproject.com/en/1.11/ref/settings/#login-redirect-url 130 | 131 | LOGIN_REDIRECT_URL = "app" 132 | LOGIN_URL = "login" 133 | 134 | 135 | # Internationalization 136 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 137 | 138 | LANGUAGE_CODE = "en-us" 139 | 140 | TIME_ZONE = "UTC" 141 | 142 | USE_I18N = True 143 | 144 | 145 | USE_TZ = True 146 | 147 | 148 | # Static files (CSS, JavaScript, Images) 149 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 150 | 151 | STATIC_URL = "/static/" 152 | 153 | 154 | # Crispy forms 155 | # http://django-crispy-forms.readthedocs.io/ 156 | 157 | CRISPY_TEMPLATE_PACK = "bootstrap4" 158 | 159 | 160 | # All these settings will be made available to javascript app 161 | SETTINGS_EXPORT = [ 162 | "DEBUG", 163 | "SITE_URL", 164 | "STATIC_URL", 165 | ] 166 | 167 | if DEBUG: 168 | # Trick to have debug toolbar when developing with docker 169 | import socket 170 | 171 | hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) 172 | INTERNAL_IPS += [".".join(ip.split(".")[:-1]) + ".1" for ip in ips] 173 | -------------------------------------------------------------------------------- /projement/app/webpack/config.base.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const BundleTracker = require('webpack-bundle-tracker'); 6 | const autoprefixer = require('autoprefixer'); 7 | 8 | 9 | // The app/ dir 10 | const app_root = path.resolve(__dirname, '..'); 11 | // The django app's dir 12 | const project_root = path.resolve(app_root, '..'); 13 | 14 | // Enable deprecation warnings 15 | process.traceDeprecation = true; 16 | 17 | 18 | function makeConfig(options) { 19 | const mode = options.mode || 'none'; 20 | const output = { 21 | path: path.resolve(app_root, 'build'), 22 | filename: options.filenameTemplate + '.js', 23 | chunkFilename: options.filenameTemplate + '.chunk.js', 24 | publicPath: options.publicPath, 25 | library: 'projement', 26 | hashFunction: 'sha512', 27 | hashDigestLength: 32, 28 | }; 29 | 30 | return { 31 | entry: { 32 | app: options.prependSources.concat(['@babel/polyfill', 'main.js']), 33 | styles: options.prependSources.concat([path.resolve(project_root, 'static', 'styles-src', 'main.js')]), 34 | }, 35 | 36 | mode, 37 | 38 | output, 39 | 40 | module: { 41 | rules: [{ 42 | test: /\.js$/, // Transform all .js files required somewhere with Babel 43 | exclude: /node_modules/, 44 | use: 'babel-loader', 45 | }, { 46 | test: /\.(css|scss)$/, 47 | include: [ 48 | // CSS modules should only be generated from css/scss files within the src directory 49 | path.resolve(app_root, 'src'), 50 | 51 | // Global stylesheets in the static directory do not generate modules 52 | path.resolve(project_root, 'static'), 53 | path.resolve(project_root, 'node_modules') 54 | ], 55 | use: [ 56 | // When MiniCssExtractPlugin becomes stable and supports all options, convert if needed 57 | MiniCssExtractPlugin.loader, 58 | { 59 | loader: 'css-loader', 60 | options: { 61 | sourceMap: true, 62 | importLoaders: 1, 63 | modules: { 64 | localIdentName: '[path][name]__[local]--[hash:base64:5]', 65 | getLocalIdent: (loaderContext, localIdentName, localName, options) => { 66 | // Everything that comes from our global style folder and node_modules will be in global scope 67 | if (/styles-src|node_modules/.test(loaderContext.resourcePath)) { 68 | return localName; 69 | } 70 | // Everything listed under vendorCss will be in global scope 71 | if (vendorCss.includes(loaderContext.resourcePath)) { 72 | return localName; 73 | } 74 | 75 | return null; 76 | }, 77 | }, 78 | }, 79 | }, { 80 | loader: "postcss-loader", 81 | options: { 82 | postcssOptions: { 83 | plugins: function () { 84 | return [autoprefixer]; 85 | }, 86 | }, 87 | }, 88 | }, { 89 | loader: "resolve-url-loader", 90 | }, { 91 | loader: "sass-loader", 92 | options: { 93 | sourceMap: true, 94 | sassOptions: { 95 | includePaths: [ 96 | path.resolve(project_root, 'static', 'styles-src'), 97 | path.resolve(project_root, 'node_modules', 'bootstrap-sass', 'assets', 'stylesheets'), 98 | ], 99 | outputStyle: 'expanded', 100 | }, 101 | }, 102 | }, 103 | ], 104 | }, { 105 | test: /\.(jpe?g|png|gif|svg|woff2?|eot|ttf)$/, 106 | type: 'asset/inline', 107 | }, { 108 | test: /\.md$/i, 109 | use: 'raw-loader', 110 | }, 111 | { 112 | test: /\.(html)$/, 113 | use:[{loader: 'html-loader',}], 114 | }], 115 | }, 116 | 117 | plugins: [ 118 | new MiniCssExtractPlugin({ 119 | filename: options.filenameTemplate + '.css', 120 | chunkFilename: options.filenameTemplate + '.chunk.css', 121 | }), 122 | new BundleTracker({ 123 | path: __dirname, 124 | filename: 'webpack-stats.json', 125 | indent: 2, 126 | logTime: true, 127 | }), 128 | new webpack.DefinePlugin({ 129 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), // Inject environmental variables to client side 130 | }), 131 | ].concat(options.plugins), 132 | 133 | optimization: { 134 | minimize: options.minimize, 135 | 136 | splitChunks: { 137 | chunks: 'all', 138 | 139 | cacheGroups: { 140 | default: false, 141 | defaultVendors: { 142 | idHint: 'vendors', 143 | test: /node_modules/, // Include all assets in node_modules directory 144 | reuseExistingChunk: true, 145 | enforce: true, 146 | chunks: 'initial', 147 | minChunks: 1, 148 | }, 149 | }, 150 | }, 151 | runtimeChunk: 'single', 152 | }, 153 | 154 | resolve: { 155 | modules: ['app/src', 'node_modules'], 156 | extensions: ['.js'], 157 | }, 158 | 159 | devtool: options.devtool, 160 | target: 'web', // Make web variables accessible to webpack, e.g. window 161 | // stats: false, // Don't show stats in the console 162 | 163 | performance: options.performance, 164 | }; 165 | } 166 | 167 | 168 | module.exports = makeConfig; 169 | -------------------------------------------------------------------------------- /projement/app/README.adoc: -------------------------------------------------------------------------------- 1 | :toc: 2 | = Projement front-end 3 | 4 | The front-end of this application is a React app that's rendered inside a Django 5 | template (link:../templates/app.html[`app.html`]) and that fetches required data 6 | from the REST API that the back-end exposes. 7 | 8 | == Folder structure 9 | 10 | The source code for the front-end is in the link:src[`src/`] folder. The webpack 11 | configuration is in the link:webpack[`webpack/`] folder and the built JavaScript 12 | files that Django will serve are built to the link:build[`build/`] folder (You most 13 | likely don't need to touch either of these directories). 14 | 15 | The `src/` folder is split into folders by "domain", so the folder structure 16 | looks similar to the Django project. Instead of having a few folders at the top 17 | level like `src/components/` and `src/pages/`, the first folders should be 18 | similar to the Django `apps`. Each front-end `app` can include a few different 19 | kinds of files and folders. Let's take the `projects` example: 20 | 21 | [source,text] 22 | ---- 23 | projects 24 | ├── ducks // Redux ducks related to this project (check out the Redux section in this readme) 25 | ├── forms // Forms built using Formik 26 | │ ├── EditProjectForm // The form for editing the time spent on a project 27 | │ │ ├── EditProjectForm.js // The component is declared here 28 | │ │ ├── EditProjectForm.test.js // And the tests for that component 29 | │ │ └── index.js // This exports the component so that it can be imported like so: 30 | │ │ // `import EditProjectForm from 'projects/forms/EditProjectForm'` 31 | │ └── index.js // This exports all forms so that it's even more convenient to 32 | │ // import: `import { EditProjectForm } from 'projects/forms'` 33 | ├── pages // Pages related to projects, just normal react components 34 | │ ├── DashboardPage // Follows the same structure as a component 35 | │ ├── EditProjectPage 36 | │ └── index.js 37 | ├── propTypes.js // React Prop Types related to projects 38 | └── testUtils.js // Some test utilities (for example, generating a mock project object) 39 | 40 | ---- 41 | 42 | The other `app` in the front-end is link:src/core[`core`]. This includes things 43 | that are not really related to any specific `app` but are related to the 44 | application as a whole. So, things like the link:src/core/Navbar[`Navbar` 45 | component], utility components like 46 | link:src/core/PrivateRoute[`PrivateRoute`], and utilities like 47 | link:src/core/testUtils.js[`testUtils`] go here. Also, the `core` app includes 48 | generic "set up" and configuration files. For example, setting up the Redux 49 | store happens in link:src/core/store.js[`store.js`]. 50 | 51 | == Testing 52 | 53 | https://testing-library.com/docs/react-testing-library/intro[React Testing 54 | Library] is used 55 | for testing React components in this project. It features a limited API to test 56 | React components exactly like how they are used by the users of the application. 57 | This means that there's no testing of internal states, props or method 58 | invocations, the main things that should be tested are the things that the user 59 | can see and interact with. 60 | 61 | Test files are located next to the thing that they are testing. For example, the 62 | tests for `Navbar.js` are located in the `Navbar.test.js` file next to 63 | `Navbar.js`. 64 | 65 | You can run front-end tests with the `make test-node` command. 66 | 67 | If you want to test things related to routing, Redux or HTTP requests, check out 68 | the sections for those (<>, <> and 69 | <>). 70 | 71 | == Routing 72 | 73 | https://reacttraining.com/react-router/[React Router] is used for routing in 74 | the front-end. The routes are declared in the link:src/core/App.js[`App` 75 | component]. There is also a utility component 76 | link:src/core/PrivateRoute[`PrivateRoute`] that redirects the user to the login 77 | page if they are not logged in. 78 | 79 | An example on how to test components that depend on the router can be seen in 80 | the 81 | link:src/projects/forms/EditProjectPage/EditProjectPage.test.js[`EditProjectPage.test.js`]. 82 | In a nutshell, the component needs to be rendered inside a React Router `Router` 83 | component and it might be necessary to wrap it in a `Route` component as well. 84 | The utility for this is the link:src/core/testUtils.js[`renderWithContext` function in the core test 85 | utilities]. 86 | 87 | _Note_ that this is generally not how we do routing in our projects but this 88 | solution is quite simple and works well enough for a test assignment. 89 | 90 | == Redux store 91 | 92 | Most of our projects use Redux for state management. There's not a whole lot 93 | there, but it's similar to how it's set up in most of our projects. We use Ducks 94 | as a way to organize our Redux store. In a nutshell, this means that all related 95 | reducers/actions/selectors/etc. are located next to each other in one file. You 96 | can read more about the Ducks idea here: 97 | https://github.com/erikras/ducks-modular-redux/[`ducks-modular-redux`]. 98 | 99 | Generally, Ducks can include the following logic: 100 | 101 | * Actions 102 | * Reducers 103 | * Action creators 104 | * Asynchronous action creators for making HTTP requests, for example (using 105 | https://github.com/reduxjs/redux-thunk[Redux Thunk]) 106 | * Selectors 107 | 108 | In this application, the only Duck we have is the link:src/projects/ducks/projects.js[`projects` 109 | duck]. It includes the following logic: 110 | 111 | * Fetching the projects - `fetchProjects` function 112 | * Update a project - `updateProject` function 113 | * Store the projects in the Redux store - action creators and reducers 114 | * Keep track of the loading state for projects 115 | * Selectors for selecting data from the Redux store 116 | 117 | The whole Redux store for the application is quite small: 118 | 119 | [source,js] 120 | ---- 121 | { 122 | projects: { 123 | projects: [ // List of projects as they are received from the API 124 | {id: 1, name: 'GateMe', company: {...}, ...} 125 | ], 126 | isLoading: false, // If anything related to projects is loading 127 | }, 128 | } 129 | ---- 130 | 131 | If you want to test a component that wants to use the Redux store, you can use 132 | the `renderWithContext` utility function from 133 | link:src/core/testUtils.js[`testUtils`] to wrap the component in a Redux provider. 134 | See an example in 135 | link:src/projects/pages/DashboardPage/DashboardPage.test.js[`DashboardPage.test.js`]. 136 | 137 | == Communication with the back-end 138 | 139 | The communication with the back-end is done via HTTP requests to various API 140 | endpoints to `/api/*` URLs. 141 | 142 | As briefly mentioned in the Redux store section, this project uses Redux Thunk 143 | to make HTTP requests. This means that a component can conveniently dispatch an 144 | action to start any kind of HTTP request. For example, the link:src/projects/pages/DashboardPage/DashboardPage.js[`DashboardPage` 145 | component] calls 146 | `fetchProjects()` if the component wants to trigger the fetching of projects. 147 | 148 | If you want to test a component that makes some HTTP requests, you can use 149 | http://www.wheresrhys.co.uk/fetch-mock/[`fetch-mock`] to mock the HTTP 150 | request. See an example in 151 | link:src/projects/pages/DashboardPage/DashboardPage.test.js[`DashboardPage.test.js`]. 152 | 153 | == Forms 154 | 155 | We generally use https://github.com/jaredpalmer/formik[Formik] to build forms. 156 | An example of this can be seen in the 157 | link:src/projects/forms/EditProjectForm/EditProjectForm.js[`EditProjectForm`]. 158 | 159 | == Linting 160 | 161 | ESLint and Prettier are set up and should work out of the box. You can run `make 162 | eslint` to check your code for linting errors. 163 | 164 | === Formatting 165 | 166 | `make eslint-fix` will have eslint try to fix any errors which it is able to. 167 | 168 | == Notes 169 | 170 | Depending on your development environment set up, you might need to install the 171 | `npm` packages locally (it can help your editor with autocompletion and 172 | linting): 173 | 174 | [source,bash] 175 | ---- 176 | # From the project root 177 | cd projement 178 | npm install 179 | ---- 180 | 181 | You should now have the packages installed locally to `projement/node_modules` 182 | and you should be able to run `npm` scripts locally (not through Docker): 183 | 184 | [source,bash] 185 | ---- 186 | npm run test 187 | npm run lint 188 | ---- 189 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | :toc: 2 | 3 | = Projement - simple project management tool 4 | 5 | ---- 6 | All rights reserved by Thorgate Management OÜ. 7 | The contents of this repository can be reproduced, copied, quoted or 8 | distributed only with written permission of Thorgate Management OÜ. 9 | ---- 10 | 11 | == Project overview 12 | 13 | Projement is a simplified tool for project managers. Project managers can have 14 | an overview of all the projects in a company. This includes estimated and actual 15 | hours spent on _design_, _development_ and _testing_. 16 | 17 | [IMPORTANT] 18 | =========== 19 | This README just outlines the test assignment tasks and basic project setup. 20 | Make sure to check out the `README` files for the front-end and back-end. 21 | As they document how to run tests as well as linters. 22 | 23 | * Back-end readme: link:projement/README.adoc[projement/README.adoc] 24 | * Front-end readme: link:projement/app/README.adoc[projement/app/README.adoc] 25 | =========== 26 | 27 | 28 | === Structure overview 29 | 30 | NOTE: It is best to follow the structure section of the README in GitHub or 31 | GitLab since the links work best there. 32 | The assignment view of the web app does not handle linking to other files or folders. 33 | 34 | The application is split into two parts – the back-end (written in Python & 35 | Django), and the front-end (written in JavaScript & React). 36 | 37 | The general folder structure for the project can be seen below: 38 | 39 | ---- 40 | ├── docker # Includes Docker files for both front and back-end 41 | ├── Makefile # A bunch of utility commands to help developers 42 | ├── projement # The Django app, back-end of the project 43 | │ ├── requirements.txt # Python dependencies 44 | │ ├── app # The React app, front-end of the project 45 | │ │ └── README.md # Useful information about the front-end 46 | │ └── README.md # Useful information about the back-end 47 | └── README.md # General overview of the project & the assignments (this file) 48 | 49 | ---- 50 | 51 | To perform some routine operations, a link:Makefile[Makefile] with a set of `make` 52 | commands is provided. In order to run these commands, the GNU `make` utility 53 | needs to be installed. Some of the commands are listed below. 54 | 55 | TIP: To see exactly what a make command is doing, you can run it with the `-n` argument. + 56 | `make migrate -n` will output: + 57 | ```echo -e "\033[0;36mRunning django migrations:\033[0m" + 58 | docker-compose run --rm django ./manage.py migrate ``` + 59 | This that you can see how to run arbitrary django or node commands. 60 | 61 | 62 | 63 | 64 | === Setup 65 | 66 | 67 | ==== System Prerequisites 68 | 69 | To be able to run the project in Docker environment, it's necessary to have 70 | https://docs.docker.com/[`docker`] and 71 | https://docs.docker.com/compose/[`docker-compose`] installed.\ 72 | 73 | TIP: Please refer to 74 | https://docs.docker.com/install/[Docker installation docs] and + 75 | https://docs.docker.com/compose/install/[Docker Compose 76 | installation docs] to install them. 77 | 78 | 79 | === QuickStart 80 | 81 | To build and setup the application from the ground up, just type: 82 | 83 | [source,bash] 84 | ---- 85 | make setup 86 | ---- 87 | 88 | This will create the necessary Docker containers and install the required 89 | Python and NPM packages. 90 | 91 | ==== Database 92 | 93 | To start the database will be empty. 94 | 95 | `make setup` also runs migrations automatically. 96 | 97 | To manually migrate the database, run: 98 | 99 | ---- 100 | make migrate 101 | ---- 102 | 103 | 104 | ===== Application data 105 | 106 | At the start the project has no data in the database. No users, or projects. 107 | 108 | .To create a superuser: 109 | ---- 110 | make superuser 111 | ---- 112 | 113 | .To load initial data for projects: 114 | ---- 115 | make load_initial_data 116 | ---- 117 | 118 | 119 | 120 | ==== Running the application 121 | 122 | ---- 123 | make runserver 124 | ---- 125 | 126 | After a successful startup, the application should be accessible at 127 | http://127.0.0.1:8000. 128 | 129 | [TIP] 130 | ===== 131 | There are a number of other useful `make` commands which you can check out with 132 | `make help` or by looking at the link:Makefile[Makefile]. 133 | 134 | Also make commands can be chained together: `make setup superuser load_initial_data` will run all the above commands in order. 135 | ===== 136 | 137 | 138 | == The Assignment 139 | 140 | === Read before you start. 141 | 142 | *Make sure to read through the whole assignment before you start writing your 143 | solutions. The last tasks might be more complicated than the first ones and, 144 | depending on the implementation, they might be related to each other.* 145 | 146 | * Please use the best practices known to you to make the commits and manage 147 | branches in the repository. 148 | * We've set up formatters and linters for both back-end and front-end to help enforce best 149 | practices. You can run the linters with `make quality`. 150 | * We expect our engineers to provide high quality features and therefore to test 151 | the parts of their code that they feel should be tested. The same applies to 152 | you. 153 | ** There are some example tests for both back-end and front-end, feel free to 154 | use them as a reference. 155 | ** You can run tests for both back-end and front-end with `make test`. 156 | ** If you're running out of time or are not sure how to test a specific 157 | thing, add a comment where you describe what you would test and which 158 | scenarios you would test. 159 | * If you have any ideas on how to improve Projement - either on the 160 | architectural side, back-end implementation, code quality or developer 161 | experience - please write them down inside your Merge request. 162 | ** This project is a simplified example of our project structure. Which is why 163 | some of the tools used here are not current best practice, partly so they can be pointed out in this assigment. 164 | ** Imagine if this project came onto your table and the client wanted to improve it, what would be the first things you would do / offer to the client? 165 | 166 | ==== If you have any issues or questions about the tasks 167 | 168 | * If minor issue document them as TODOs in code comments and work around them to figure out the best solution. 169 | * For bigger issues you can also ask us via e-mail or phone, but it might take 170 | some time until we respond. 171 | 172 | ==== Finishing the Assignment 173 | 174 | We expect an experienced full-stack developer to complete the assignment in *4-6 hours*. + 175 | Taking longer is not a problem, but you still shouldn't exceed a total of 8-10 hours. 176 | 177 | We value your own time as well, so just us know what you'd have wanted to complete, if you were to spend more time. + 178 | Please do so in a code-comment in your MR in the most relevant places. 179 | 180 | 181 | IMPORTANT: When you have finished, create a Pull Request in GitHub containing the entire solution, and request for a review from the owner of the repository. 182 | 183 | === Tasks 184 | 185 | ==== 1. Fix project ordering on the dashboard 186 | 187 | Currently, the projects on the dashboard are ordered by start date. The project managers want to see them in a different order. 188 | 189 | *As a result of this task:* 190 | 191 | * Projects on the dashboard must be ordered by end date descendingly. 192 | * Projects that have not ended yet, must be shown first. 193 | * Make sure that the projects' list in the Django admin has the same ordering. 194 | 195 | ==== 2. Actual hours need to be decimals 196 | 197 | Currently, all the actual hours (design, development, testing) for the `Project` model are integers. 198 | Project managers want to have more precision - they need to be changed to decimals. 199 | 200 | *As a result of this task:* 201 | 202 | * The actual hours fields must be `DecimalField`s. 203 | * The actual hours must be in the range of `0 <= x < 10000` and have 2 decimal places. 204 | * All other changes necessary to keep the application running, must be made (e.g. migrations). 205 | * Make sure that it's possible to save the decimal values through the front-end 206 | as well. 207 | 208 | ==== 3. Incremental changes 209 | 210 | When two people edit the same project at the same time, and both want to increase actual hours by 10, they end up with faulty results. 211 | 212 | For example, if the actual development hours are currently 25 in a project, and two developers begin 213 | editing the form simultaneously, then both have an initial value of 25 in the form. 214 | They both did 10 hours of work, and thus insert 35 as the development hours. 215 | 216 | After both have submitted the form, the actual development hours stored in the database are 35, 217 | even though both developers did 10 hours of work and the resulting value should be 45 (25+10+10). 218 | 219 | This issue applies for all actual hours: development, design and testing. 220 | 221 | *As a result of this task:* 222 | 223 | * Instead of entering the total amount of actual hours, the user only has to enter the additional amount of development, design and testing hours that they have spent since last update. 224 | * It must be possible for two users to enter their additional hours simultaneously, with both entries taken into account. 225 | 226 | ==== 4. Weird results for "project has ended" 227 | 228 | There are some weird results for the "project has ended" indicator in the 229 | Dashboard (when a project's name has been crossed out). We're not sure what 230 | exactly the problem is. The crossing out of projects seems to be pretty random 231 | at the moment. 232 | 233 | *As a result of this task:* 234 | 235 | * The projects should be correctly crossed out if they have actually ended. 236 | 237 | ==== 5. Slow dashboard 238 | 239 | The project managers have noticed that the dashboard gets slower and slower when 240 | more projects have been added. We think that it might be because the database 241 | queries are not optimized. 242 | 243 | *As a result of this task:* 244 | 245 | * The dashboard performance issues should be solved. 246 | 247 | *Note:* You can use a management command to generate a lot of projects: 248 | 249 | [source,bash] 250 | ---- 251 | # Creates 300 projects by default 252 | make loadmanyprojects 253 | # You can also specify the number of projects to create: 254 | make loadmanyprojects cmd="--nr-of-projects 100" 255 | ---- 256 | 257 | *Note:* This task should be done before pagination (Task 6) has been 258 | implemented as pagination can help a bit with performance. We would like a 259 | different solution from pagination in this task. 260 | 261 | ==== 6. Add pagination to the dashboard 262 | 263 | There are quite a lot of projects in the application and the project managers 264 | have noticed that the dashboard can get a bit slow and they would prefer not to 265 | scroll through a hundred projects. It would help if the list of projects in the 266 | dashboard was paginated. 267 | 268 | *As a result of this task:* 269 | 270 | * The dashboard has a paginated list of projects. 271 | * The pagination should happen without a full reload of the page. 272 | 273 | Describe how you would make the pagination and its user experience better if you 274 | don't manage to implement everything. For example, write these as TODO-s in the 275 | README.md. 276 | 277 | ==== 7. (Bonus) Replace SQLite with a better database management system 278 | 279 | The project currently uses https://sqlite.org/[SQLite] as its database engine. 280 | SQLite is great, but it's not very well suited for large web applications (like 281 | Projement). It makes sense to move to something more scalable like 282 | https://www.postgresql.org/[PostgreSQL] or https://www.mysql.com/[MySQL]. 283 | 284 | *As a result of this task:* 285 | 286 | * The project should use PostgreSQL, MySQL, or some other more advanced database 287 | management system. 288 | * The database should run inside Docker and be started along with the rest of 289 | the application when running `docker-compose up` or `make runserver`. 290 | * The database should not lose any data between Docker restarts. For example, if 291 | the Docker containers are stopped (`docker-compose down`) and started again 292 | (`docker-compose up`). 293 | --------------------------------------------------------------------------------