diff --git a/.gitignore b/.gitignore index b6e4761..5450889 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + + +# PyCharm +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ddbfc2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.8.5-alpine3.12 +MAINTAINER Moath Zaghdad + +ENV PYTHONUNBUFFERD 1 + + +RUN apk --no-cache add gettext +RUN apk add --update --no-cache postgresql-client +RUN apk add --update --no-cache --virtual .temp-build-deps \ + gcc libc-dev linux-headers postgresql-dev +RUN pip3 install --no-cache-dir pipenv + +COPY ./Pipfile /Pipfile +COPY ./Pipfile.lock /Pipfile.lock +RUN pipenv install --dev --system + +RUN apk del .temp-build-deps + + +RUN mkdir -p /app +WORKDIR /app +COPY ./app /app + +RUN adduser -D user +USER user diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..36cfd50 --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +flake8 = "*" + +[packages] +django = "~=3.1" +djangorestframework = "~=3.11.1" +psycopg2 = "*" +django-model-utils = "*" + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..b5dd603 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,119 @@ +{ + "_meta": { + "hash": { + "sha256": "72afc589a1dd1534ea3da3ccc9ef3a076c3c867c54668f4de44073725fff7ca3" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "asgiref": { + "hashes": [ + "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a", + "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed" + ], + "markers": "python_version >= '3.5'", + "version": "==3.2.10" + }, + "django": { + "hashes": [ + "sha256:59c8125ca873ed3bdae9c12b146fbbd6ed8d0f743e4cf5f5817af50c51f1fc2f", + "sha256:b5fbb818e751f660fa2d576d9f40c34a4c615c8b48dd383f5216e609f383371f" + ], + "index": "pypi", + "version": "==3.1.1" + }, + "django-model-utils": { + "hashes": [ + "sha256:9cf882e5b604421b62dbe57ad2b18464dc9c8f963fc3f9831badccae66c1139c", + "sha256:adf09e5be15122a7f4e372cb5a6dd512bbf8d78a23a90770ad0983ee9d909061" + ], + "index": "pypi", + "version": "==4.0.0" + }, + "djangorestframework": { + "hashes": [ + "sha256:6dd02d5a4bd2516fb93f80360673bf540c3b6641fec8766b1da2870a5aa00b32", + "sha256:8b1ac62c581dbc5799b03e535854b92fc4053ecfe74bad3f9c05782063d4196b" + ], + "index": "pypi", + "version": "==3.11.1" + }, + "psycopg2": { + "hashes": [ + "sha256:00195b5f6832dbf2876b8bf77f12bdce648224c89c880719c745b90515233301", + "sha256:068115e13c70dc5982dfc00c5d70437fe37c014c808acce119b5448361c03725", + "sha256:26e7fd115a6db75267b325de0fba089b911a4a12ebd3d0b5e7acb7028bc46821", + "sha256:56007a226b8e95aa980ada7abdea6b40b75ce62a433bd27cec7a8178d57f4051", + "sha256:56fee7f818d032f802b8eed81ef0c1232b8b42390df189cab9cfa87573fe52c5", + "sha256:6a3d9efb6f36f1fe6aa8dbb5af55e067db802502c55a9defa47c5a1dad41df84", + "sha256:a49833abfdede8985ba3f3ec641f771cca215479f41523e99dace96d5b8cce2a", + "sha256:ad2fe8a37be669082e61fb001c185ffb58867fdbb3e7a6b0b0d2ffe232353a3e", + "sha256:b8cae8b2f022efa1f011cc753adb9cbadfa5a184431d09b273fb49b4167561ad", + "sha256:d160744652e81c80627a909a0e808f3c6653a40af435744de037e3172cf277f5", + "sha256:f22ea9b67aea4f4a1718300908a2fb62b3e4276cf00bd829a97ab5894af42ea3", + "sha256:f974c96fca34ae9e4f49839ba6b78addf0346777b46c4da27a7bf54f48d3057d", + "sha256:fb23f6c71107c37fd667cb4ea363ddeb936b348bbd6449278eb92c189699f543" + ], + "index": "pypi", + "version": "==2.8.6" + }, + "pytz": { + "hashes": [ + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + ], + "version": "==2020.1" + }, + "sqlparse": { + "hashes": [ + "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", + "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.3.1" + } + }, + "develop": { + "flake8": { + "hashes": [ + "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", + "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" + ], + "index": "pypi", + "version": "==3.8.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "pycodestyle": { + "hashes": [ + "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", + "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.6.0" + }, + "pyflakes": { + "hashes": [ + "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", + "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.2.0" + } + } +} diff --git a/README.md b/README.md index d0ef978..0f06350 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# task-012 \ No newline at end of file +# task-012 + +# Docker Setup +```bash +docker-compose build +docker-compose up -d +docker-compose exec time_tracking sh -c "python manage.py migrate" +docker-compose exec time_tracking sh -c "python manage.py loaddata fixtures/users.json" +docker-compose exec time_tracking sh -c "python manage.py compilemessages" +docker-compose exec time_tracking sh -c "python manage.py createcachetable" +docker-compose exec time_tracking sh -c "python manage.py test" # run the tests +docker-compose exec time_tracking sh -c "flake8" # run flake8 +``` + +# PostgreSQL Environment variables +`DB_NAME`, `USER`, `PASSWORD`, `HOST` + diff --git a/app/.flake8 b/app/.flake8 new file mode 100644 index 0000000..3f68f57 --- /dev/null +++ b/app/.flake8 @@ -0,0 +1,6 @@ +[flake8] +exclude = + migrations, + __pycache__, + manage.py, + settings.py diff --git a/app/fixtures/users.json b/app/fixtures/users.json new file mode 100644 index 0000000..ff68cc4 --- /dev/null +++ b/app/fixtures/users.json @@ -0,0 +1,92 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$216000$F0dlaJuv7AsL$duDS6o1yMmazBQnnOZLVU3NMLWzHB9pcCyjI9Dcav+4=", + "last_login": null, + "is_superuser": true, + "username": "admin", + "first_name": "", + "last_name": "", + "email": "admin@admin.admin", + "is_staff": true, + "is_active": true, + "date_joined": "2020-08-06T12:10:24.399Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$180000$7pqm6zAaWmCe$l7cCgbICilx5kz8LHJ5xh0g1Zx1Q+xuRkFh8fx8AnZI=", + "last_login": null, + "is_superuser": false, + "username": "waseem", + "first_name": "", + "last_name": "", + "email": "waseem@foundertherapy.co", + "is_staff": false, + "is_active": true, + "date_joined": "2020-08-06T12:10:24.399Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$180000$htF9zN6ud9Fr$NCqkFGNoc/zcfJuSpTO3HDjq7GowV/TuwnYde73X3m0=", + "last_login": null, + "is_superuser": false, + "username": "motasem", + "first_name": "", + "last_name": "", + "email": "motasem@foundertherapy.co", + "is_staff": false, + "is_active": true, + "date_joined": "2020-08-06T12:10:24.399Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "auth.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$180000$33CjJbgsof0J$2/m9gv/36qRfgS7ZAwgMsEq2UFLy2mfVjAABdYOrWDA=", + "last_login": null, + "is_superuser": false, + "username": "ayman", + "first_name": "", + "last_name": "", + "email": "ayman@foundertherapy.co", + "is_staff": false, + "is_active": true, + "date_joined": "2020-08-06T12:10:24.399Z", + "groups": [], + "user_permissions": [] + } + }, + { + "model": "auth.user", + "pk": 5, + "fields": { + "password": "pbkdf2_sha256$180000$A0MgsVEXpfMN$t5fo3EnchdIFTUFnkryDAk/Uwl3lYt5VUbiCF9IYzzU=", + "last_login": null, + "is_superuser": false, + "username": "moath", + "first_name": "", + "last_name": "", + "email": "moath.zaghdad@pm.me", + "is_staff": false, + "is_active": true, + "date_joined": "2020-08-06T12:10:24.399Z", + "groups": [], + "user_permissions": [] + } + } +] diff --git a/app/locale/en/LC_MESSAGES/django.po b/app/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..340c605 --- /dev/null +++ b/app/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,28 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-08-08 22:31+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: time_tracking/vacation/views.py:45 +msgid "Reached max vacations days" +msgstr "Sorry, you already reached the max vacations days a year." + +#: time_tracking/vacation/views.py:53 +#, python-format +msgid "Can't add more than %(days)s" +msgstr "Sorry, you can't add more than %(days)s day." diff --git a/app/manage.py b/app/manage.py new file mode 100755 index 0000000..3808a83 --- /dev/null +++ b/app/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'time_tracking.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/app/time_tracking/__init__.py b/app/time_tracking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/asgi.py b/app/time_tracking/asgi.py new file mode 100644 index 0000000..4624481 --- /dev/null +++ b/app/time_tracking/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for time_tracking project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'time_tracking.settings') + +application = get_asgi_application() diff --git a/app/time_tracking/event/__init__.py b/app/time_tracking/event/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/event/admin.py b/app/time_tracking/event/admin.py new file mode 100644 index 0000000..8cd3c4d --- /dev/null +++ b/app/time_tracking/event/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from time_tracking.event.models import Event + +admin.site.register(Event) diff --git a/app/time_tracking/event/apps.py b/app/time_tracking/event/apps.py new file mode 100644 index 0000000..13b1f16 --- /dev/null +++ b/app/time_tracking/event/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EventConfig(AppConfig): + name = 'event' diff --git a/app/time_tracking/event/migrations/0001_initial.py b/app/time_tracking/event/migrations/0001_initial.py new file mode 100644 index 0000000..7c2bd03 --- /dev/null +++ b/app/time_tracking/event/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1 on 2020-08-06 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=120)), + ('description', models.TextField(blank=True, null=True)), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ], + options={ + 'ordering': ['-start_time'], + }, + ), + ] diff --git a/app/time_tracking/event/migrations/0002_alter_event_DateTime_to_Date.py b/app/time_tracking/event/migrations/0002_alter_event_DateTime_to_Date.py new file mode 100644 index 0000000..bba06cf --- /dev/null +++ b/app/time_tracking/event/migrations/0002_alter_event_DateTime_to_Date.py @@ -0,0 +1,68 @@ +# Generated by Django 3.0.9 on 2020-08-07 05:41 + +from django.db import migrations, models +import django.utils.timezone +from time_tracking.event.models import Event + +def set_my_defaults(apps, schema_editor): + Events = apps.get_model('event', 'Event') + for event in Events.objects.all().iterator(): + event.end_date = event.end_time.date() + event.start_date = event.start_time.date() + event.save() + +def reverse_func(apps, schema_editor): + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('event', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='event', + options={'ordering': ['-start_date']}, + ), + migrations.AddField( + model_name='event', + name='end_date', + field=models.DateField(null=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='event', + name='start_date', + field=models.DateField(null=True, default=django.utils.timezone.now), + preserve_default=False, + ), + + + migrations.RunPython(set_my_defaults, reverse_func), + + + migrations.RemoveField( + model_name='event', + name='end_time', + ), + migrations.RemoveField( + model_name='event', + name='start_time', + ), + + + + migrations.AlterField( + model_name='event', + name='end_date', + field=models.DateField(default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name='event', + name='start_date', + field=models.DateField(default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/app/time_tracking/event/migrations/0003_added_created_by.py b/app/time_tracking/event/migrations/0003_added_created_by.py new file mode 100644 index 0000000..c32f7d4 --- /dev/null +++ b/app/time_tracking/event/migrations/0003_added_created_by.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-08-09 05:40 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('event', '0002_alter_event_DateTime_to_Date'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='created_by', + field=models.ForeignKey(limit_choices_to={'is_staff': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/app/time_tracking/event/migrations/0004_added_created_at_and_updated_at_fields.py b/app/time_tracking/event/migrations/0004_added_created_at_and_updated_at_fields.py new file mode 100644 index 0000000..288e673 --- /dev/null +++ b/app/time_tracking/event/migrations/0004_added_created_at_and_updated_at_fields.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1 on 2020-08-12 22:19 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('event', '0003_added_created_by'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2020, 8, 12, 22, 19, 36, 694343, tzinfo=utc)), + preserve_default=False, + ), + migrations.AddField( + model_name='event', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/app/time_tracking/event/migrations/0005_updated_the_description_to_not_nullable_field.py b/app/time_tracking/event/migrations/0005_updated_the_description_to_not_nullable_field.py new file mode 100644 index 0000000..8de4b01 --- /dev/null +++ b/app/time_tracking/event/migrations/0005_updated_the_description_to_not_nullable_field.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2020-09-09 23:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('event', '0004_added_created_at_and_updated_at_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='description', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/app/time_tracking/event/migrations/0006_replaced_create_at_and_updated_at_with_TimeStampedModel.py b/app/time_tracking/event/migrations/0006_replaced_create_at_and_updated_at_with_TimeStampedModel.py new file mode 100644 index 0000000..80c4d8e --- /dev/null +++ b/app/time_tracking/event/migrations/0006_replaced_create_at_and_updated_at_with_TimeStampedModel.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1.1 on 2020-09-13 16:50 + +from django.db import migrations +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('event', '0005_updated_the_description_to_not_nullable_field'), + ] + + operations = [ + migrations.RenameField( + model_name='event', + old_name='created_at', + new_name='created', + ), + migrations.AlterField( + model_name='event', + name='created', + field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'), + ), + migrations.RenameField( + model_name='event', + old_name='updated_at', + new_name='modified', + ), + migrations.AlterField( + model_name='event', + name='modified', + field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'), + ), + ] diff --git a/app/time_tracking/event/migrations/__init__.py b/app/time_tracking/event/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/event/models.py b/app/time_tracking/event/models.py new file mode 100644 index 0000000..73ef2f8 --- /dev/null +++ b/app/time_tracking/event/models.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import User +from django.db import models +from model_utils.models import TimeStampedModel + + +class Event(TimeStampedModel): + + created_by = models.ForeignKey( + User, limit_choices_to={'is_staff': True}, + on_delete=models.SET_NULL, + related_name='+', null=True + ) + + title = models.CharField(max_length=120) + description = models.TextField(blank=True, default='') + start_date = models.DateField() + end_date = models.DateField() + + class Meta: + ordering = ['-start_date'] diff --git a/app/time_tracking/event/permissions.py b/app/time_tracking/event/permissions.py new file mode 100644 index 0000000..a90a216 --- /dev/null +++ b/app/time_tracking/event/permissions.py @@ -0,0 +1,15 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsStaffMemberOrReadOnly(BasePermission): + """ + Allow only staff members to edit/create/update/delete while read + permissions "GET, HEAD or OPTIONS requests" are allowed to any request. + """ + + def has_permission(self, request, view): + return bool( + request.method in SAFE_METHODS or + request.user and + request.user.is_staff is True + ) diff --git a/app/time_tracking/event/serializers.py b/app/time_tracking/event/serializers.py new file mode 100644 index 0000000..ff93b73 --- /dev/null +++ b/app/time_tracking/event/serializers.py @@ -0,0 +1,33 @@ +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from time_tracking.event.models import Event + + +class EventSerializer(serializers.ModelSerializer): + + class Meta: + model = Event + fields = [ + 'id', 'url', 'title', 'description', 'start_date', 'end_date' + ] + + def validate(self, data): + """ + Check that start date is before end date + """ + if data.get('start_date') is None: + start_date = self.instance.start_date + else: + start_date = data['start_date'] + + if data.get('end_date') is None: + end_date = self.instance.end_date + else: + end_date = data['end_date'] + + if start_date > end_date: + raise serializers.ValidationError( + _("End date must occur after Start date") + ) + return data diff --git a/app/time_tracking/event/tests.py b/app/time_tracking/event/tests.py new file mode 100644 index 0000000..121580c --- /dev/null +++ b/app/time_tracking/event/tests.py @@ -0,0 +1,208 @@ +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.test import TestCase +from datetime import datetime, timedelta + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.event.models import Event + + +EVENTS_URL = reverse('event-list') +VALID_PAYLOAD = { + 'title': 'school visit day', + 'description': '''Lorem ipsum dolor sit amet, consectetur adipiscing + ipsum sit amet tortor varius placerat. Quisque justo nisi, ultricies nec + iaoreet sed, lacinia nec ante. Vivamus vehicula faucibus neque vel vehicula + Ut consectetur lectus ac dolor imperdiet, vestibulum ex ultrices. In mattis + placerat dictum. Etiam imperdiet nec dolor a convallis. Nullam vitae dolor + consequat, feugiat nulla eget, porta libero. Suspendisse potenti.''', + 'start_date': '2020-09-10', + 'end_date': '2020-10-15' +} + + +def detail_url(event_id): + """Return event detail URL""" + return reverse('event-detail', args=[event_id]) + + +def sample_event(user, **params): + """Create and return a sample event""" + defaults = { + 'title': 'Sample event', + 'description': 'Sample event description', + 'start_date': '2020-09-1', + 'end_date': '2020-9-2' + } + defaults.update(params) + + return Event.objects.create(created_by=user, **defaults) + + +class PublicEventsApiTests(TestCase): + """Test the publicly available events API""" + + def setUp(self): + self.client = APIClient() + + def test_login_not_required(self): + """Test that login is not required to access the endpoint""" + res = self.client.get(EVENTS_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_create_event_unauthorized(self): + """Test that login is required to create a new event""" + res = self.client.post(EVENTS_URL, VALID_PAYLOAD) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + exists = Event.objects.all().exists() + self.assertFalse(exists) + + +class StaffUsersEventsApiTests(TestCase): + """Test staff members events API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=True, + ) + self.client.force_authenticate(self.user) + + def test_create_event_successful(self): + """Test create a new event""" + res = self.client.post(EVENTS_URL, VALID_PAYLOAD) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + exists = Event.objects.filter( + title=VALID_PAYLOAD['title'], + description=VALID_PAYLOAD['description'], + start_date=VALID_PAYLOAD['start_date'], + end_date=VALID_PAYLOAD['end_date'] + ).exists() + self.assertTrue(exists) + + def test_full_update_event_successful(self): + """Test update an event""" + event = sample_event(user=self.user) + url = detail_url(event.id) + start_date = datetime.strptime(event.start_date, '%Y-%m-%d').date() + end_date = datetime.strptime(event.end_date, '%Y-%m-%d').date() + payload = { + 'title': event.title + '-new title', + 'description': event.description + 'new description', + 'start_date': start_date + timedelta(days=2), + 'end_date': end_date + timedelta(days=5), + } + + res = self.client.put(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + event.refresh_from_db() + self.assertEqual(event.title, payload['title']) + self.assertEqual(event.description, payload['description']) + self.assertEqual(event.start_date, payload['start_date']) + self.assertEqual(event.end_date, payload['end_date']) + + def test_partially_update_event_successful(self): + """Test partially update an event""" + event = sample_event(user=self.user) + url = detail_url(event.id) + payload = { + 'title': event.title + '-new title', + } + + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + event.refresh_from_db() + self.assertEqual(event.title, payload['title']) + + def test_delete_event(self): + """Test delete an event""" + event = sample_event(user=self.user) + url = detail_url(event.id) + + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + exists = Event.objects.all().exists() + self.assertFalse(exists) + + def test_create_event_with_end_date_before_the_start_date(self): + """Test create an event where the start date is after the end date""" + payload = { + 'title': 'event title', + 'start_date': '2020-5-5', + 'end_date': '2020-1-1', + } + + res = self.client.post(EVENTS_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + +class NonStaffUsersRequestsEventsApiTests(TestCase): + """Test non-staff members events API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_create_event_forbidden(self): + """Test create a new event with unauthorized user""" + res = self.client.post(EVENTS_URL, VALID_PAYLOAD) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + exists = Event.objects.all().exists() + self.assertFalse(exists) + + def test_full_update_event_forbidden(self): + """Test update an event with unauthorized user""" + event = sample_event(user=self.user) + url = detail_url(event.id) + start_date = datetime.strptime(event.start_date, '%Y-%m-%d').date() + end_date = datetime.strptime(event.end_date, '%Y-%m-%d').date() + payload = { + 'title': event.title + '-new title', + 'description': event.description + '-new description', + 'start_date': start_date + timedelta(days=2), + 'end_date': end_date + timedelta(days=5), + } + + res = self.client.put(url, payload) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + def test_partially_update_event_unauthorized(self): + """Test partially-update an event with unauthorized user""" + event = sample_event(user=self.user) + url = detail_url(event.id) + payload = { + 'title': event.title + '-new title' + } + + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_event_unauthorized(self): + """Test delete an event with unauthorized user""" + event = sample_event(user=self.user) + url = detail_url(event.id) + + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) diff --git a/app/time_tracking/event/views.py b/app/time_tracking/event/views.py new file mode 100644 index 0000000..946b08b --- /dev/null +++ b/app/time_tracking/event/views.py @@ -0,0 +1,18 @@ +from rest_framework import viewsets + +from time_tracking.event.models import Event +from time_tracking.event.permissions import IsStaffMemberOrReadOnly +from time_tracking.event.serializers import EventSerializer + + +class EventViewSet(viewsets.ModelViewSet): + """ + This viewset provides `list` and `detail` actions for all the users + and provides `create`, `update`, and `delete` for the staff members + """ + queryset = Event.objects.all() + serializer_class = EventSerializer + permission_classes = [IsStaffMemberOrReadOnly] + + def perform_create(self, serializer): + serializer.save(created_by=self.request.user) diff --git a/app/time_tracking/settings.py b/app/time_tracking/settings.py new file mode 100644 index 0000000..43082a8 --- /dev/null +++ b/app/time_tracking/settings.py @@ -0,0 +1,152 @@ +""" +Django settings for time_tracking project. + +Generated by 'django-admin startproject' using Django 3.0.9. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 't(02xojmyf#nukock)%*3koh4-&0-3fdk4v=rx1o2n3f^*(g2s' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'rest_framework', + 'rest_framework.authtoken', + + 'time_tracking.event', + 'time_tracking.vacation', + 'time_tracking.work_time', + 'time_tracking.work_statistic', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'time_tracking.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'time_tracking.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASSWORD'), + 'HOST': os.environ.get('DB_HOST'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale') +] + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' + + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 15, +} + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', + 'LOCATION': 'work_times_cache_table', + } +} \ No newline at end of file diff --git a/app/time_tracking/tests.py b/app/time_tracking/tests.py new file mode 100644 index 0000000..66b92c6 --- /dev/null +++ b/app/time_tracking/tests.py @@ -0,0 +1,43 @@ +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.test import TestCase + +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework.authtoken.models import Token + + +TOKEN_AUTH_URL = reverse('api-token-auth') + + +class TokenAuthenticationApiTests(TestCase): + """Test the users can get the token from the API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + username='test', + email='test@test.com', + password='123qwe' + ) + self.client.force_authenticate(self.user) + + def test_invalid_parameters_should_not_return_a_token(self): + """ + Test that the endpoint will return error when user password is invalid + """ + payload = {"username": self.user.username, "password": "x123qwe"} + res = self.client.post(TOKEN_AUTH_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_get_token_with_valid_user_should_return_valid_token(self): + """ + Test that the endpoint will return a valid token to the valid user + """ + payload = {"username": self.user.username, "password": "123qwe"} + res = self.client.post(TOKEN_AUTH_URL, payload) + token = Token.objects.get_or_create(user=self.user) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(res.data['token'] in str(token)) diff --git a/app/time_tracking/urls.py b/app/time_tracking/urls.py new file mode 100644 index 0000000..424328c --- /dev/null +++ b/app/time_tracking/urls.py @@ -0,0 +1,67 @@ +"""time_tracking URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from rest_framework.authtoken import views +from rest_framework.routers import DefaultRouter + +from rest_framework.urlpatterns import format_suffix_patterns +from time_tracking.event.views import EventViewSet +from time_tracking.vacation.views import VacationViewSet +from time_tracking.work_statistic.views import ( + EmployeesArrivalAndLeavingTimesStatisticsDetail, + WorkTimeStatisticsDetail, + WorkTimeUsersAvailableStatistics, + WorkingHoursToLeavingHoursStatisticsDetail, +) +from time_tracking.work_time.views import WorkTimeViewSet + + +# Create a router and register our viewsets with it. +router = DefaultRouter() +router.register(r'events', EventViewSet, basename='event') +router.register(r'vacation', VacationViewSet, basename='vacation') + +router.register(r'work-time', WorkTimeViewSet, basename='worktime') + +router.register(r'work-time-statistics', + WorkTimeUsersAvailableStatistics, + basename='worktime-available-statistics') + +router.register(r'work-time-statistic/arrive-and-leave-times', + EmployeesArrivalAndLeavingTimesStatisticsDetail, + basename='work-arrival-and-leaving-time-statistic') + +router.register(r'team-statistics/work-to-leave-time-average', + WorkingHoursToLeavingHoursStatisticsDetail, + basename='work-hours-to-leave-hours-statistic') + +# The API URLs are now determined automatically by the router. +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include(router.urls)), +] + +urlpatterns += format_suffix_patterns([ + path(r'work-time-statistic///', + WorkTimeStatisticsDetail.as_view(), + name='work-time-periods-statistic'), +]) + +urlpatterns += [ + path('api-token-auth/', views.obtain_auth_token, name='api-token-auth'), + path('api-auth/', include('rest_framework.urls')), +] diff --git a/app/time_tracking/vacation/__init__.py b/app/time_tracking/vacation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/vacation/admin.py b/app/time_tracking/vacation/admin.py new file mode 100644 index 0000000..ab4b8e5 --- /dev/null +++ b/app/time_tracking/vacation/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from time_tracking.vacation.models import Vacation + +admin.site.register(Vacation) diff --git a/app/time_tracking/vacation/apps.py b/app/time_tracking/vacation/apps.py new file mode 100644 index 0000000..67dce83 --- /dev/null +++ b/app/time_tracking/vacation/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class VacationConfig(AppConfig): + name = 'vacation' diff --git a/app/time_tracking/vacation/migrations/0001_initial.py b/app/time_tracking/vacation/migrations/0001_initial.py new file mode 100644 index 0000000..a756418 --- /dev/null +++ b/app/time_tracking/vacation/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1 on 2020-08-06 17:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Vacation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brief_description', models.CharField(blank=True, max_length=120, null=True)), + ('start_date', models.DateField()), + ('number_of_days', models.PositiveSmallIntegerField()), + ('owner', models.ForeignKey(limit_choices_to={'is_staff': False}, on_delete=django.db.models.deletion.CASCADE, related_name='vacations_applications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-start_date'], + }, + ), + ] diff --git a/app/time_tracking/vacation/migrations/0002_added_created_at_and_updated_at_fields.py b/app/time_tracking/vacation/migrations/0002_added_created_at_and_updated_at_fields.py new file mode 100644 index 0000000..4ea2a2e --- /dev/null +++ b/app/time_tracking/vacation/migrations/0002_added_created_at_and_updated_at_fields.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1 on 2020-08-12 22:19 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('vacation', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vacation', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2020, 8, 12, 22, 19, 40, 702081, tzinfo=utc)), + preserve_default=False, + ), + migrations.AddField( + model_name='vacation', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/app/time_tracking/vacation/migrations/0003_updated_the_brief_description_to_not_nullable_field.py b/app/time_tracking/vacation/migrations/0003_updated_the_brief_description_to_not_nullable_field.py new file mode 100644 index 0000000..712c6d6 --- /dev/null +++ b/app/time_tracking/vacation/migrations/0003_updated_the_brief_description_to_not_nullable_field.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.1 on 2020-09-09 23:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vacation', '0002_added_created_at_and_updated_at_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='vacation', + name='brief_description', + field=models.CharField(blank=True, default='', max_length=120), + ), + ] diff --git a/app/time_tracking/vacation/migrations/0004_replaced_number_of_days_field_wtih_end_date.py b/app/time_tracking/vacation/migrations/0004_replaced_number_of_days_field_wtih_end_date.py new file mode 100644 index 0000000..beb80e5 --- /dev/null +++ b/app/time_tracking/vacation/migrations/0004_replaced_number_of_days_field_wtih_end_date.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.1 on 2020-09-09 23:35 + +import datetime +from datetime import timedelta +from django.db import migrations, models +from django.utils.timezone import utc + + +def calculate_start_and_end_days(apps, schema_editor): + Vacation = apps.get_model('vacation', 'Vacation') + for vacation in Vacation.objects.all().iterator(): + if vacation.end_date is None: + vacation.end_date = vacation.start_date + timedelta(vacation.number_of_days) + vacation.save() + +def reverse_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('vacation', '0003_updated_the_brief_description_to_not_nullable_field'), + ] + + operations = [ + migrations.AddField( + model_name='vacation', + name='end_date', + field=models.DateField(null=True), + ), + migrations.RunPython(calculate_start_and_end_days, reverse_func), + migrations.RemoveField( + model_name='vacation', + name='number_of_days', + ), + migrations.AlterField( + model_name='vacation', + name='end_date', + field=models.DateField(), + ), + ] + + diff --git a/app/time_tracking/vacation/migrations/0005_replaced_create_at_and_updated_at_with_TimeStampedModel.py b/app/time_tracking/vacation/migrations/0005_replaced_create_at_and_updated_at_with_TimeStampedModel.py new file mode 100644 index 0000000..3a7d351 --- /dev/null +++ b/app/time_tracking/vacation/migrations/0005_replaced_create_at_and_updated_at_with_TimeStampedModel.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1.1 on 2020-09-13 16:46 + +from django.db import migrations +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('vacation', '0004_replaced_number_of_days_field_wtih_end_date'), + ] + + operations = [ + migrations.RenameField( + model_name='vacation', + old_name='created_at', + new_name='created', + ), + migrations.AlterField( + model_name='vacation', + name='created', + field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'), + ), + migrations.RenameField( + model_name='vacation', + old_name='updated_at', + new_name='modified', + ), + migrations.AlterField( + model_name='vacation', + name='modified', + field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'), + ), + ] diff --git a/app/time_tracking/vacation/migrations/__init__.py b/app/time_tracking/vacation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/vacation/models.py b/app/time_tracking/vacation/models.py new file mode 100644 index 0000000..8681592 --- /dev/null +++ b/app/time_tracking/vacation/models.py @@ -0,0 +1,23 @@ +from django.contrib.auth.models import User +from django.db import models +from model_utils.models import TimeStampedModel + + +class Vacation(TimeStampedModel): + + brief_description = models.CharField( + max_length=120, + blank=True, + default='' + ) + start_date = models.DateField() + end_date = models.DateField() + owner = models.ForeignKey( + User, + limit_choices_to={'is_staff': False}, + on_delete=models.CASCADE, + related_name='vacations_applications', + ) + + class Meta: + ordering = ['-start_date'] diff --git a/app/time_tracking/vacation/permissions.py b/app/time_tracking/vacation/permissions.py new file mode 100644 index 0000000..01cf9fb --- /dev/null +++ b/app/time_tracking/vacation/permissions.py @@ -0,0 +1,16 @@ +from rest_framework.permissions import BasePermission + + +class IsOwner(BasePermission): + """ + Custom permission to only allow owners of an object to edit it. + """ + + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + """ + Write permissions are only allowed to the owner of the vacation. + """ + return obj.owner == request.user diff --git a/app/time_tracking/vacation/serializers.py b/app/time_tracking/vacation/serializers.py new file mode 100644 index 0000000..c2143fc --- /dev/null +++ b/app/time_tracking/vacation/serializers.py @@ -0,0 +1,72 @@ +from django.utils import timezone +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from time_tracking.vacation.models import Vacation + + +class VacationSerializer(serializers.ModelSerializer): + owner = serializers.ReadOnlyField(source='owner.username') + + class Meta: + model = Vacation + fields = [ + 'id', 'url', 'brief_description', + 'start_date', 'end_date', 'owner' + ] + + def validate(self, data): + """ + Check that start date is not after the end date + """ + if data.get('end_date') is None: + end_date = self.instance.end_date + else: + end_date = data['end_date'] + + if data.get('start_date') is None: + start_date = self.instance.end_date + else: + start_date = data['start_date'] + + if end_date < start_date: + raise serializers.ValidationError( + _("end_date must not occur before start_date") + ) + + if (end_date - start_date).days + 1 > 16: + raise serializers.ValidationError( + _("You can't have a vacation for more than 16 day!") + ) + return data + + def validate_start_date(self, value): + """ + Check that the start_date was not modify and that + the start_date is not on the past + """ + if self.instance and self.instance.start_date != value: + raise serializers.ValidationError( + _("Changing start_date is not allowed") + ) + + if self.instance is None and value < timezone.now().date(): + raise serializers.ValidationError( + _("start_date can not be from the past") + ) + return value + + def validate_end_date(self, value): + """ + Check that the end_date was not modify + """ + if self.instance and self.instance.end_date != value: + raise serializers.ValidationError( + _("Changing end_date is not allowed") + ) + + if self.instance is None and value < timezone.now().date(): + raise serializers.ValidationError( + _("end_date can not be from the past") + ) + return value diff --git a/app/time_tracking/vacation/tests.py b/app/time_tracking/vacation/tests.py new file mode 100644 index 0000000..01f40f6 --- /dev/null +++ b/app/time_tracking/vacation/tests.py @@ -0,0 +1,350 @@ +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.test import TestCase +from django.utils import timezone +from datetime import date, timedelta + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.vacation.models import Vacation +from time_tracking.event.models import Event + +VACATION_URL = reverse('vacation-list') + + +def detail_url(vacation_id): + """Return vacation detail URL""" + return reverse('vacation-detail', args=[vacation_id]) + + +def get_future_year_starting_date(add_years=2): + """ + calculates the date at the beginning of the next second year + + :return: datetime.date + """ + return date(timezone.now().date().year + add_years, 1, 1) + + +def sample_vacation(user, add_years=0, number_of_days=1): + """Create and return a sample event""" + defaults = { + 'brief_description': 'Sample vacation brief description', + 'start_date': get_future_year_starting_date(add_years), + 'end_date': ( + get_future_year_starting_date(add_years) + + timedelta(number_of_days - 1) + ) + } + + return Vacation.objects.create(owner=user, **defaults) + + +class PublicVacationsApiTests(TestCase): + """Test the publicly available Vacations API""" + + def setUp(self): + self.client = APIClient() + + def test_login_required(self): + """Test that login is required to access the endpoint""" + res = self.client.get(VACATION_URL) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_create_a_vacation_without_authentication_should_fail(self): + """Test that login is required to create a new Vacations""" + payload = { + 'brief_description': 'test title', + 'start_date': get_future_year_starting_date(), + 'number_of_days': 18 + } + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PrivateVacationsApiTests(TestCase): + """Test the private vacations API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_create_event_successful(self): + """Test create a new Vacation""" + payload = { + 'brief_description': 'test brief description', + 'start_date': get_future_year_starting_date() + timedelta(days=1), + 'end_date': get_future_year_starting_date() + timedelta(days=1), + } + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + exists = Vacation.objects.filter( + brief_description=payload['brief_description'], + start_date=payload['start_date'], + end_date=payload['end_date'], + owner=self.user, + ).exists() + self.assertTrue(exists) + + def test_add_more_than_16_days_with_one_request(self): + """Test request with more than 16 days vacation should fail""" + payload = { + 'brief_description': 'test brief description', + 'start_date': get_future_year_starting_date() + timedelta(days=1), + 'end_date': get_future_year_starting_date() + timedelta(days=17), + } + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn( + "You can't have a vacation for more than 16 day!", + res.data.get('non_field_errors')[0] + ) + + def test_add_more_than_16_days_with_two_requests(self): + """ + Test requests with a result sum more than 16 days with in\ + the same year should fail + """ + payload1 = { + 'brief_description': 'test brief description 1', + 'start_date': get_future_year_starting_date() + timedelta(days=1), + 'end_date': get_future_year_starting_date() + timedelta(days=12), + } + payload2 = { + 'brief_description': 'test brief description 2', + 'start_date': get_future_year_starting_date() + timedelta(days=14), + 'end_date': get_future_year_starting_date() + timedelta(days=19), + } + + res1 = self.client.post(VACATION_URL, payload1) + res2 = self.client.post(VACATION_URL, payload2) + + self.assertEqual(res1.status_code, status.HTTP_201_CREATED) + self.assertEqual(res2.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("can't add more than 4 day", res2.data.get('detail')) + + def test_add_vacation_days_when_last_year_vacations_are_full(self): + """ + Test requests with less than 17 vacation days while the vacations\ + before that year are fully taken. + """ + sample_vacation(self.user, add_years=-1, number_of_days=16) + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(days=15), + } + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + def test_full_update_vacation_successful(self): + """Test update a vacation description""" + vacation = sample_vacation(user=self.user) + url = detail_url(vacation.id) + payload = { + 'brief_description': vacation.brief_description + '-new update', + 'start_date': vacation.start_date, + 'end_date': vacation.end_date, + } + + res = self.client.put(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + vacation.refresh_from_db() + self.assertEqual(vacation.brief_description, + payload['brief_description']) + self.assertEqual(vacation.start_date, payload['start_date']) + self.assertEqual(vacation.end_date, payload['end_date']) + + def test_partially_update_event_successful(self): + """Test partially update a vacation""" + vacation = sample_vacation(user=self.user) + url = detail_url(vacation.id) + payload = { + 'brief_description': vacation.brief_description + '-new update', + } + + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + vacation.refresh_from_db() + self.assertEqual(vacation.brief_description, + payload['brief_description']) + + def test_delete_future_vacation_successful(self): + """Test delete a future vacation""" + vacation = sample_vacation(user=self.user, add_years=1) + url = detail_url(vacation.id) + + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + + def test_delete_an_old_vacation_should_fail(self): + """Test delete an old vacation is forbidden""" + vacation = sample_vacation(user=self.user, add_years=-1) + url = detail_url(vacation.id) + + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + +class PrivateVacationsWithEventsApiTests(TestCase): + """Test the private vacations while having events API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_add_vacation_after_the_event_date(self): + """Test add a vacation after an event""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['start_date'] - timedelta(3), + 'end_date': payload['start_date'] - timedelta(1) + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + def test_add_vacation_before_the_event_date(self): + """Test add a vacation before an event""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['end_date'] + timedelta(1), + 'end_date': payload['end_date'] + timedelta(3) + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + def test_add_vacation_on_the_same_day_as_an_event(self): + """Test add a vacation that intersects with an event""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['start_date'], + 'end_date': payload['end_date'] + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertTrue(len(res.data.get('events_urls')) == 1) + + def test_add_vacation_when_the_event_start_before_the_vacation(self): + """Test add a vacation that intersects with an event start_date""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['start_date'] - timedelta(5), + 'end_date': payload['end_date'] - timedelta(5) + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertTrue(len(res.data.get('events_urls')) == 1) + + def test_add_vacation_when_the_vacation_starts_before_the_event(self): + """Test add a vacation that intersects with an event end_date""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['start_date'] + timedelta(5), + 'end_date': payload['end_date'] + timedelta(5) + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertTrue(len(res.data.get('events_urls')) == 1) + + def test_add_vacation_when_the_vacation_is_inside_the_event_dates(self): + """Test add a vacation that vacation is included in the event dates""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['start_date'] - timedelta(1), + 'end_date': payload['end_date'] + timedelta(1) + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertTrue(len(res.data.get('events_urls')) == 1) + + def test_add_vacation_when_the_event_is_inside_the_vacation_dates(self): + """Test add a vacation that event is included in the vacation dates""" + payload = { + 'brief_description': 'test brief description', + 'start_date': timezone.now().date(), + 'end_date': timezone.now().date() + timedelta(15) + } + event = { + 'title': 'school visit day', + 'start_date': payload['start_date'] + timedelta(1), + 'end_date': payload['end_date'] - timedelta(1) + } + event = Event.objects.create(created_by=self.user, **event) + + res = self.client.post(VACATION_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertTrue(len(res.data.get('events_urls')) == 1) diff --git a/app/time_tracking/vacation/views.py b/app/time_tracking/vacation/views.py new file mode 100644 index 0000000..da2de45 --- /dev/null +++ b/app/time_tracking/vacation/views.py @@ -0,0 +1,96 @@ +from datetime import date +from django.db.models import Sum, F +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +from rest_framework import status +from rest_framework import viewsets +from rest_framework.response import Response + +from time_tracking.event.models import Event +from time_tracking.vacation.models import Vacation +from time_tracking.vacation.permissions import IsOwner +from time_tracking.vacation.serializers import VacationSerializer + + +class VacationViewSet(viewsets.ModelViewSet): + """ + This viewset provides `list`, `detail`, `create`, + `update`, and `delete` for all users. + """ + serializer_class = VacationSerializer + permission_classes = [IsOwner] + + def get_queryset(self): + return Vacation.objects.filter(owner=self.request.user) + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + vacations = Vacation.objects.filter( + owner=self.request.user, + start_date__gte=date(timezone.now().year, 1, 1), + ).aggregate(sum=Sum(1 + F('end_date') - F('start_date'))) + + if vacations['sum'] is not None: + if vacations['sum'].days >= 16: + return Response( + {"detail": _("Reached max vacations days")}, + status=status.HTTP_400_BAD_REQUEST + ) + + requested_vacation_days = 1 + ( + serializer.validated_data['end_date'] + - serializer.validated_data['start_date'] + ).days + remaining_days = 16 - vacations['sum'].days + if remaining_days - requested_vacation_days < 0: + return Response( + {"detail": _("Can't add more than %(days)s") % { + 'days': remaining_days + }}, + status=status.HTTP_400_BAD_REQUEST + ) + + events_intersection = Event.objects.filter( + end_date__gte=serializer.validated_data['start_date'] + ).filter( + start_date__lte=serializer.validated_data['end_date'] + ).exclude( + start_date__gt=serializer.validated_data['end_date'] + ).exclude( + end_date__lt=serializer.validated_data['start_date'] + ).values('id') + + if len(events_intersection) != 0: + return Response( + {"detail": _("Events are intersection with your vacation"), + "events_urls": [( + request.build_absolute_uri( + reverse('event-detail', args=[event['id']]) + ) for event in events_intersection + )]}, + status=status.HTTP_406_NOT_ACCEPTABLE + ) + + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, headers=headers + ) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.start_date < timezone.now().date(): + return Response( + {"detail": _("You can't delete old vacation dates")}, + status=status.HTTP_403_FORBIDDEN + ) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/time_tracking/work_statistic/__init__.py b/app/time_tracking/work_statistic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/work_statistic/apps.py b/app/time_tracking/work_statistic/apps.py new file mode 100644 index 0000000..9457fbd --- /dev/null +++ b/app/time_tracking/work_statistic/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WorkStatisticConfig(AppConfig): + name = 'work_statistic' diff --git a/app/time_tracking/work_statistic/serializers.py b/app/time_tracking/work_statistic/serializers.py new file mode 100644 index 0000000..f5b5ecc --- /dev/null +++ b/app/time_tracking/work_statistic/serializers.py @@ -0,0 +1,57 @@ +from rest_framework import serializers +from django.contrib.auth.models import User +from rest_framework.reverse import reverse + + +class EmployeesArrivalAndLeavingTimesSerializer(serializers.Serializer): + username = serializers.StringRelatedField() + average_arrival_time = serializers.TimeField(allow_null=True) + average_leaving_time = serializers.TimeField(allow_null=True) + + +class TeamWorkingToLeavingTimeStatisticsSerializer(serializers.Serializer): + working_hours = serializers.IntegerField() + leaving_hours = serializers.IntegerField() + percentage_of_working_on_leaving_time = serializers.FloatField() + + +class UserTotalWorkingHoursStatisicsSerializer(serializers.Serializer): + total_working_hours = serializers.IntegerField() + + +class UsersAvailableWorkTimeStatisticsSerializer( + serializers.HyperlinkedModelSerializer +): + + id = serializers.ReadOnlyField() + username = serializers.ReadOnlyField() + + working_hours_to_leaving_hours = serializers.HyperlinkedIdentityField( + view_name='work-arrival-and-leaving-time-statistic-detail', + lookup_url_kwarg='user_id', + ) + total_working_hours = serializers.SerializerMethodField() + + url = serializers.HyperlinkedIdentityField( + view_name='worktime-available-statistics-detail' + ) + + class Meta: + model = User + fields = [ + 'id', 'url', 'username', + 'total_working_hours', 'working_hours_to_leaving_hours' + ] + + def get_total_working_hours(self, obj): + periods = ['week', 'quarter', 'year'] + return { + period: reverse( + 'work-time-periods-statistic', + kwargs={ + 'period': period, + 'user_id': obj.pk + }, + request=self.context['request'], + ) for period in periods + } diff --git a/app/time_tracking/work_statistic/tests/__init__.py b/app/time_tracking/work_statistic/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/work_statistic/tests/test_employees_arrival_and_leaving_times_statistics_detail.py b/app/time_tracking/work_statistic/tests/test_employees_arrival_and_leaving_times_statistics_detail.py new file mode 100644 index 0000000..aea726e --- /dev/null +++ b/app/time_tracking/work_statistic/tests/test_employees_arrival_and_leaving_times_statistics_detail.py @@ -0,0 +1,202 @@ +from datetime import timedelta +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.work_time.models import WorkTime + + +def detail_url(user_id): + """Return event detail URL""" + return reverse('work-arrival-and-leaving-time-statistic-detail', args=[user_id]) + + +class PublicEmployeesArrivalAndLeavingTimesStatisticsDetailApiTests(TestCase): + """Test the publicly available employeesArrivalAndLeavingTimesStatisticsDetail API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + + def test_accessing_the_detail_endpoint_without_logged_in_user(self): + """ + Test that login is required to access the detail users statistic endpoint + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class NoneStaffMemberEmployeesArrivalAndLeavingTimesStatisticsDetailApiTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_accessing_the_detail_endpoint_with_logged_in_user(self): + """ + Test that the detail users statistic endpoint is not allowed for the users + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + +class StaffMembersEmployeesArrivalAndLeavingTimesStatisticsDetailApiTests(TestCase): + """Test the Staff Members Statistic API""" + + def setUp(self): + self.client = APIClient() + self.other_user = get_user_model().objects.create_user( + 'other-test-user', + 'other-test@test.com', + '123qwe', + is_staff=False, + ) + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.admin = get_user_model().objects.create_user( + 'admin-test-user' + 'admin-test@test.com', + '123qwe', + is_staff=True, + ) + self.client.force_authenticate(self.admin) + + def test_detail_user_should_be_allowed_for_staff_members(self): + """ + Test that the user available statistics detail is available staff members + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data['username'], self.user.username) + + def test_that_none_will_be_return_when_there_are_no_records_for_the_user(self): + """ + Test that the endpoint will return None when there are no records for the user + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, { + 'username': self.user.username, + 'average_arrival_time': None, + 'average_leaving_time': None, + }) + + def test_the_average_arrival_time_for_the_user(self): + """ + Test that the return average_arrival_time for the user is correct + """ + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + this_year_start = today_datetime - timedelta(days=712) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=today_datetime + timedelta(days=1), + end_datetime=today_datetime + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=101, minutes=23), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start, + end_datetime=this_year_start + timedelta(days=1, hours=2, seconds=31), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=121, hours=11), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=333, hours=3), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=1, hours=1, minutes=50, seconds=5, milliseconds=500, microseconds=500), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime, + owner=self.user, + ), + ]) + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data['username'], self.user.username) + self.assertEqual(res.data['average_arrival_time'], '03:10:01.100100') + + def test_the_average_leaving_time_for_the_user(self): + """ + Test that the return average_leaving_time for the user is correct + """ + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + this_year_start = today_datetime - timedelta(days=712) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=today_datetime + timedelta(days=1), + end_datetime=today_datetime + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=101, minutes=23), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start, + end_datetime=this_year_start + timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=121, hours=11), + end_datetime=this_year_start + timedelta(days=1, hours=23, minutes=48, seconds=9), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=333, hours=3), + end_datetime=this_year_start + timedelta(days=1, hours=10, seconds=51, microseconds=320), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=1, hours=1, minutes=49, seconds=5, microseconds=500500), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime, + end_datetime=this_year_start + timedelta(days=1, hours=7, seconds=34, milliseconds=404), + owner=self.user, + ), + ]) + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data['username'], self.user.username) + self.assertEqual(res.data['average_leaving_time'], '10:42:23.601080') diff --git a/app/time_tracking/work_statistic/tests/test_work_time_statistics_detail.py b/app/time_tracking/work_statistic/tests/test_work_time_statistics_detail.py new file mode 100644 index 0000000..a571654 --- /dev/null +++ b/app/time_tracking/work_statistic/tests/test_work_time_statistics_detail.py @@ -0,0 +1,310 @@ +from datetime import timedelta +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.work_time.models import WorkTime + + +PERIODS_NAMES = dict(week='week', quarter='quarter', year='year') + + +def detail_url(period, user_id): + """Return work-time statistics detail URL""" + return reverse('work-time-periods-statistic', args=[period, user_id]) + + +class PublicWorkTimeStatisticsDetailApiTests(TestCase): + """Test the publicly available statistic API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=False, + ) + + def test_accessing_the_endpoint_without_logged_in_user(self): + """ + Test that login is required to access the users available statistics endpoint + """ + url = detail_url(PERIODS_NAMES['week'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class NoneStaffMemberWorkTimeStatisticsDetailApiTests(TestCase): + """Test the None Staff Members statistic API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_accessing_the_endpoint_with_logged_in_user(self): + """ + Test that access the users statistics endpoint is not allowed for the users + """ + url = detail_url(PERIODS_NAMES['week'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + +class StaffMemberWorkTimeStatisticsDetailApiTests(TestCase): + """Test the Staff Members statistic API""" + + def setUp(self): + self.client = APIClient() + self.other_user = get_user_model().objects.create_user( + 'other-test-user', + 'other-test@test.com', + '123qwe', + is_staff=False, + ) + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.admin = get_user_model().objects.create_user( + 'admin-test-user' + 'admin-test@test.com', + '123qwe', + is_staff=True, + ) + self.client.force_authenticate(self.admin) + + def test_accessing_the_week_statistics_endpoint_with_logged_in_staff_member(self): + """ + Test that access the users' week statistics endpoint is available for staff members + """ + url = detail_url(PERIODS_NAMES['week'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 0}) + + def test_accessing_the_quarter_statistics_endpoint_with_logged_in_staff_member(self): + """ + Test that access the users' quarter statistics endpoint is available for staff members + """ + url = detail_url(PERIODS_NAMES['quarter'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 0}) + + def test_accessing_the_year_statistics_endpoint_with_logged_in_staff_member(self): + """ + Test that access the users' year statistics endpoint is available for staff members + """ + url = detail_url(PERIODS_NAMES['year'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 0}) + + def test_the_period_string_is_case_insensitive(self): + """ + Test that the period string is case insensitive + """ + url_lowercase = detail_url(PERIODS_NAMES['week'].lower(), self.user.pk) + url_uppercase = detail_url(PERIODS_NAMES['week'].upper(), self.user.pk) + res_lower = self.client.get(url_lowercase) + res_upper = self.client.get(url_uppercase) + + self.assertEqual(res_lower.status_code, status.HTTP_200_OK) + self.assertEqual(res_lower.data, {'total_working_hours': 0}) + + self.assertEqual(res_upper.status_code, status.HTTP_200_OK) + self.assertEqual(res_upper.data, {'total_working_hours': 0}) + + def test_total_working_hours_for_the_week(self): + """ + Test the total working hours result for this week + """ + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + this_week_start = today_datetime - timedelta(weeks=1) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=this_week_start - timedelta(days=1), + end_datetime=this_week_start - timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime + timedelta(days=1), + end_datetime=today_datetime + timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_week_start + timedelta(days=1), + end_datetime=this_week_start + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_week_start, + end_datetime=this_week_start + timedelta(hours=2, minutes=50, microseconds=50), + owner=self.user, + ), + WorkTime( + start_datetime=this_week_start + timedelta(days=1), + end_datetime=this_week_start + timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_week_start + timedelta(days=1, hours=9), + end_datetime=this_week_start + timedelta(days=1, hours=11), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime, + end_datetime=today_datetime + timedelta(hours=2, minutes=10), + owner=self.user, + ), + ]) + url = detail_url(PERIODS_NAMES['week'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 9}) + + def test_total_working_hours_for_the_quarter(self): + """ + Test the total working hours result for this quarter + """ + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + this_quarter_start = today_datetime - timedelta(days=91) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=this_quarter_start - timedelta(days=1), + end_datetime=this_quarter_start - timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime + timedelta(days=1), + end_datetime=today_datetime + timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_quarter_start + timedelta(days=1), + end_datetime=this_quarter_start + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_quarter_start, + end_datetime=this_quarter_start + timedelta(hours=2, minutes=50, microseconds=50), + owner=self.user, + ), + WorkTime( + start_datetime=this_quarter_start + timedelta(days=15), + end_datetime=this_quarter_start + timedelta(days=15, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_quarter_start + timedelta(days=55, hours=9), + end_datetime=this_quarter_start + timedelta(days=55, hours=11), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime, + end_datetime=today_datetime + timedelta(hours=2, minutes=10), + owner=self.user, + ), + ]) + url = detail_url(PERIODS_NAMES['quarter'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 9}) + + def test_total_working_hours_for_the_year(self): + """ + Test the total working hours result for this year + """ + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + this_year_start = today_datetime - timedelta(days=356) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=this_year_start - timedelta(days=1), + end_datetime=this_year_start - timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime + timedelta(days=1), + end_datetime=today_datetime + timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=1), + end_datetime=this_year_start + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start, + end_datetime=this_year_start + timedelta(hours=2, minutes=50, microseconds=50), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=121), + end_datetime=this_year_start + timedelta(days=121, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=333, hours=9), + end_datetime=this_year_start + timedelta(days=333, hours=11), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime, + end_datetime=today_datetime + timedelta(hours=2, minutes=10), + owner=self.user, + ), + ]) + url = detail_url(PERIODS_NAMES['year'], self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 9}) + + def test_the_staff_members_statistics_should_return_zero(self): + """ + Test the total working hours result for the staff members should be zero + """ + this_week_start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(weeks=1) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=this_week_start + timedelta(days=1), + end_datetime=this_week_start + timedelta(days=1, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_week_start + timedelta(days=1), + end_datetime=this_week_start + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_week_start + timedelta(days=1), + end_datetime=this_week_start + timedelta(days=1, hours=2), + owner=self.admin, + ), + ]) + url = detail_url(PERIODS_NAMES['year'], self.admin.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, {'total_working_hours': 0}) + diff --git a/app/time_tracking/work_statistic/tests/test_work_time_users_available_statistics.py b/app/time_tracking/work_statistic/tests/test_work_time_users_available_statistics.py new file mode 100644 index 0000000..953843e --- /dev/null +++ b/app/time_tracking/work_statistic/tests/test_work_time_users_available_statistics.py @@ -0,0 +1,123 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.work_statistic.serializers import UsersAvailableWorkTimeStatisticsSerializer + +AVAILABLE_STATISTICS_URL = reverse( + 'worktime-available-statistics-list' +) + + +def detail_url(user_id): + """Return work-time available statistics detail URL""" + return reverse('worktime-available-statistics-detail', args=[user_id]) + + +class PublicWorkTimeUsersAvailableStatisticsApiTests(TestCase): + """Test the publicly available statistic API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + + def test_accessing_the_endpoint_without_logged_in_user(self): + """ + Test that login is required to access the users available statistics endpoint + """ + res = self.client.get(AVAILABLE_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_accessing_the_detail_endpoint_without_logged_in_user(self): + """ + Test that login is required to access the detail users available statistics endpoint + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class NoneStaffMemberWorkTimeUsersAvailableStatisticsApiTests(TestCase): + """Test the None Staff Members Statistic API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_accessing_the_endpoint_with_logged_in_user(self): + """ + Test that the users available statistics endpoint is not allowed for the users + """ + res = self.client.get(AVAILABLE_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + def test_accessing_the_detail_endpoint_with_logged_in_user(self): + """ + Test that the detail users available statistics endpoint is not allowed for the users + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + +class StaffMembersWorkTimeUsersAvailableStatisticsApiTests(TestCase): + """Test the Staff Members Statistic API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.admin = get_user_model().objects.create_user( + 'admin-test-user' + 'admin-test@test.com', + '123qwe', + is_staff=True, + ) + get_user_model().objects.create_user( + 'other-test-user' + 'other-test@test.com', + '123qwe', + is_staff=False, + ) + + self.client.force_authenticate(self.admin) + + def test_list_users_should_be_allowed_for_staff_members(self): + """ + Test that the users available statistics list is available for staff members + """ + res = self.client.get(AVAILABLE_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_detail_user_should_be_allowed_for_staff_members(self): + """ + Test that the user available statistics detail is available staff members + """ + url = detail_url(self.user.pk) + res = self.client.get(url) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + diff --git a/app/time_tracking/work_statistic/tests/test_working_hours_to_leaving_hours_statistics_detail.py b/app/time_tracking/work_statistic/tests/test_working_hours_to_leaving_hours_statistics_detail.py new file mode 100644 index 0000000..9b9d2e2 --- /dev/null +++ b/app/time_tracking/work_statistic/tests/test_working_hours_to_leaving_hours_statistics_detail.py @@ -0,0 +1,147 @@ +from datetime import timedelta +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.work_time.models import WorkTime + + +WORK_TO_LEAVE_HOURS_STATISTICS_URL = reverse( + 'work-hours-to-leave-hours-statistic-list' +) + + +class PublicWorkingHoursToLeavingHoursStatisticsDetailApiTests(TestCase): + """Test the publicly available workingHoursToLeavingHoursStatisticsDetailAPI""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + + def test_accessing_the_statistics_endpoint_without_logged_in_user(self): + """ + Test that login is required to access the statistics endpoint + """ + res = self.client.get(WORK_TO_LEAVE_HOURS_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class NoneStaffMemberWorkingHoursToLeavingHoursStatisticsDetailApiTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + + def test_accessing_the_statistics_endpoint_with_logged_in_user(self): + """ + Test that the statistics endpoint is not allowed to be accessed by the users + """ + res = self.client.get(WORK_TO_LEAVE_HOURS_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) + + +class StaffMembersWorkingHoursToLeavingHoursStatisticsDetailApiTests(TestCase): + """Test the Staff Members Statistic API""" + + def setUp(self): + self.client = APIClient() + self.other_user = get_user_model().objects.create_user( + 'other-test-user', + 'other-test@test.com', + '123qwe', + is_staff=False, + ) + self.user = get_user_model().objects.create_user( + 'test-user', + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.admin = get_user_model().objects.create_user( + 'admin-test-user' + 'admin-test@test.com', + '123qwe', + is_staff=True, + ) + self.client.force_authenticate(self.admin) + + def test_accessing_the_statistic_endpoint_should_be_allowed_for_staff_members(self): + """ + Test that the available statistics is available for staff members to view + """ + res = self.client.get(WORK_TO_LEAVE_HOURS_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, { + 'working_hours': 0, + 'leaving_hours': 0, + 'percentage_of_working_on_leaving_time': None, + }) + + def test_the_accuracy_of_the_endpoint_results(self): + """ + Test the accuracy of the end workingHoursToLeavingHoursStatisticsDetailAPI + """ + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + this_year_start = today_datetime - timedelta(days=730) + wts = WorkTime.objects.bulk_create([ + WorkTime( + start_datetime=this_year_start - timedelta(days=1), + end_datetime=this_year_start - timedelta(days=1, hours=2), + owner=self.admin, + ), + WorkTime( + start_datetime=today_datetime + timedelta(days=1), + end_datetime=today_datetime + timedelta(days=1, hours=2), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=3, hours=8, minutes=30), + end_datetime=this_year_start + timedelta(days=3, hours=19, minutes=30), + owner=self.other_user, + ), + WorkTime( + start_datetime=this_year_start, + end_datetime=this_year_start + timedelta(hours=8, minutes=50, seconds=50), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=333), + end_datetime=this_year_start + timedelta(days=333, hours=2), + owner=self.user, + ), + WorkTime( + start_datetime=this_year_start + timedelta(days=333, hours=9), + end_datetime=this_year_start + timedelta(days=333, hours=11), + owner=self.user, + ), + WorkTime( + start_datetime=today_datetime, + end_datetime=today_datetime + timedelta(hours=2, minutes=50), + owner=self.user, + ), + ]) + res = self.client.get(WORK_TO_LEAVE_HOURS_STATISTICS_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data['working_hours'], 28) + self.assertEqual(res.data['leaving_hours'], 7) + self.assertGreater(res.data['percentage_of_working_on_leaving_time'], 409.72222) + self.assertLess(res.data['percentage_of_working_on_leaving_time'], 409.72223) diff --git a/app/time_tracking/work_statistic/views.py b/app/time_tracking/work_statistic/views.py new file mode 100644 index 0000000..f438e2f --- /dev/null +++ b/app/time_tracking/work_statistic/views.py @@ -0,0 +1,177 @@ +from datetime import timedelta, datetime +from django.contrib.auth.models import User +from django.core.cache import cache +from django.db.models import Sum, Avg, F, Min, Max, TimeField, DateField +from django.db.models.functions import TruncDay, TruncTime +from django.utils import timezone + +from rest_framework import status, viewsets, mixins +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from time_tracking.work_statistic.serializers import ( + EmployeesArrivalAndLeavingTimesSerializer, + TeamWorkingToLeavingTimeStatisticsSerializer, + UserTotalWorkingHoursStatisicsSerializer, + UsersAvailableWorkTimeStatisticsSerializer, +) +from time_tracking.work_time.models import WorkTime + + +class WorkTimeUsersAvailableStatistics(viewsets.ReadOnlyModelViewSet): + """ + List the users and there available statistics + """ + serializer_class = UsersAvailableWorkTimeStatisticsSerializer + permission_classes = [IsAdminUser, ] + + def get_queryset(self): + return User.objects.filter(is_staff=False) + + +class WorkTimeStatisticsDetail(APIView): + """ + Retrieve work hours since a `week`, a `quarter` or a `year` ago. + """ + permission_classes = [IsAdminUser, ] + serializer_class = UserTotalWorkingHoursStatisicsSerializer + # the periods names are only in lowercase + period_name_with_days_count = {'week': 7, 'quarter': 91, 'year': 356} + + def get_queryset(self): + return WorkTime.objects.filter( + end_datetime__isnull=False, + owner__is_staff=False, + ) + + def calculate_working_hours(self, queryset): + calc = queryset.annotate( + diff=F('end_datetime') - F('start_datetime') + ).values('diff').aggregate(sum=Sum('diff')) + seconds_sum = (calc['sum'] or timedelta()).total_seconds() + return seconds_sum / 3600 + + def get(self, request, period, user_id): + period = period.lower() + days_count = self.period_name_with_days_count.get(period) + if days_count is None or User.objects.filter(pk=user_id).exists() is False: + return Response(status=status.HTTP_400_BAD_REQUEST) + + total_working_hours = cache.get(f'total_working_hours_for_a_{period}_to_user_{user_id}') + + if total_working_hours is None: + today_datetime = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + previous_datetime = today_datetime - timedelta(days=days_count) + + total_working_hours = self.calculate_working_hours( + self.get_queryset().filter( + owner=user_id, + start_datetime__gte=previous_datetime + ).exclude(start_datetime__gt=today_datetime) + ) + cache.set( + f'total_working_hours_for_a_{period}_to_user_{user_id}', + total_working_hours, + timedelta(hours=1).total_seconds() + ) + + serializer = self.serializer_class({ + 'total_working_hours': total_working_hours + }) + + return Response( + serializer.data, + status.HTTP_200_OK + ) + + +class EmployeesArrivalAndLeavingTimesStatisticsDetail(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + """ + Retrieve the employee arrival time and leaving time. + """ + permission_classes = [IsAdminUser, ] + serializer_class = EmployeesArrivalAndLeavingTimesSerializer + lookup_url_kwarg = 'user_id' + + def get_queryset(self): + return User.objects.filter(is_staff=False) + + def retrieve(self, request, *args, **kwargs): + user = self.get_object() + query = WorkTime.objects.filter(owner=user).values( + day=TruncDay('start_datetime'), + ).annotate( + min=TruncTime(Min('start_datetime')), + max=TruncTime(Max('end_datetime')), + ) + + employees_stats = cache.get_or_set( + f'avg_arrival_and_leaving_time_for_user_{user.pk}', + query.aggregate( + avg_start=Avg('min', output_field=TimeField()), + avg_end=Avg('max', output_field=TimeField())), + timedelta(hours=1).total_seconds() + ) + + avg_arrival = None + avg_leave = None + + if employees_stats['avg_start'] is not None: + avg_arrival = datetime.utcfromtimestamp(employees_stats['avg_start'].total_seconds()).time() + + if employees_stats['avg_end'] is not None: + avg_leave = datetime.utcfromtimestamp(employees_stats['avg_end'].total_seconds()).time() + + serializer = self.get_serializer({ + 'username': user.username, + 'average_arrival_time': avg_arrival, + 'average_leaving_time': avg_leave, + }) + return Response(serializer.data) + + +class WorkingHoursToLeavingHoursStatisticsDetail(mixins.ListModelMixin, viewsets.GenericViewSet): + """ + Retrieve the team working hours to leaving hours + """ + permission_classes = [IsAdminUser, ] + serializer_class = TeamWorkingToLeavingTimeStatisticsSerializer + + def list(self, request, *args, **kwargs): + """ + working time = select all the time and sum it together + leaving time = sum all the leaving time per day for the user and sum them + """ + query = User.objects.filter(is_staff=False, work_times__end_datetime__isnull=False).annotate( + work_time=F('work_times__end_datetime') - F('work_times__start_datetime'), + end_datetime=F('work_times__end_datetime'), + start_datetime=F('work_times__start_datetime'), + day=TruncDay('work_times__start_datetime', output_field=DateField()) + ).values('username', 'day').annotate( + total_work_time_per_day=Sum('work_time'), + start_to_end_time_per_day=(Max('end_datetime') - Min('start_datetime')) + ).values( + 'username', 'total_work_time_per_day', 'start_to_end_time_per_day' + ) + + stats = cache.get_or_set( + 'team_work_to_leave_hours', query.aggregate( + leaving_hours=Sum(F('start_to_end_time_per_day') - F('total_work_time_per_day')), + working_hours=Sum('total_work_time_per_day')), + timedelta(days=1).total_seconds() + ) + + working_hours = (stats['working_hours'] or timedelta()).total_seconds() / 3600 + leaving_hours = (stats['leaving_hours'] or timedelta()).total_seconds() / 3600 + work_on_leve_time = None + if leaving_hours != 0: + work_on_leve_time = working_hours / leaving_hours * 100 + + serializer = self.get_serializer({ + 'working_hours': working_hours, + 'leaving_hours': leaving_hours, + 'percentage_of_working_on_leaving_time': work_on_leve_time, + }) + + return Response(serializer.data, status.HTTP_200_OK) diff --git a/app/time_tracking/work_time/__init__.py b/app/time_tracking/work_time/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/work_time/admin.py b/app/time_tracking/work_time/admin.py new file mode 100644 index 0000000..0f467ee --- /dev/null +++ b/app/time_tracking/work_time/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from time_tracking.work_time.models import WorkTime + +admin.site.register(WorkTime) diff --git a/app/time_tracking/work_time/apps.py b/app/time_tracking/work_time/apps.py new file mode 100644 index 0000000..ca2c19d --- /dev/null +++ b/app/time_tracking/work_time/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WorkTimeConfig(AppConfig): + name = 'work_time' diff --git a/app/time_tracking/work_time/migrations/0001_initial.py b/app/time_tracking/work_time/migrations/0001_initial.py new file mode 100644 index 0000000..4eed740 --- /dev/null +++ b/app/time_tracking/work_time/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.9 on 2020-08-07 03:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WorkTime', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField(blank=True, null=True)), + ('owner', models.ForeignKey(limit_choices_to={'is_staff': False}, on_delete=django.db.models.deletion.CASCADE, related_name='work_times', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-start_time'], + }, + ), + ] diff --git a/app/time_tracking/work_time/migrations/0002_added_work_time_field.py b/app/time_tracking/work_time/migrations/0002_added_work_time_field.py new file mode 100644 index 0000000..92c2490 --- /dev/null +++ b/app/time_tracking/work_time/migrations/0002_added_work_time_field.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.9 on 2020-08-07 06:44 + +from django.db import migrations, models + + +def calculate_finished_work_times(apps, schema_editor): + WorkTimes = apps.get_model('work_time', 'WorkTime') + for work_time in WorkTimes.objects.all().iterator(): + if work_time.start_time is not None and work_time.end_time is not None: + work_time.work_time = int((work_time.end_time - work_time.start_time).total_seconds()) + work_time.save() + +def reverse_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('work_time', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='worktime', + name='work_time', + field=models.PositiveIntegerField(blank=True, editable=False, null=True), + ), + migrations.RunPython(calculate_finished_work_times, reverse_func), + + ] diff --git a/app/time_tracking/work_time/migrations/0003_added_unix_start_and_end_time.py b/app/time_tracking/work_time/migrations/0003_added_unix_start_and_end_time.py new file mode 100644 index 0000000..25e55eb --- /dev/null +++ b/app/time_tracking/work_time/migrations/0003_added_unix_start_and_end_time.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.9 on 2020-08-07 08:55 + +from django.db import migrations, models + + +def calculate_unix_start_and_end_times(apps, schema_editor): + WorkTimes = apps.get_model('work_time', 'WorkTime') + for work_time in WorkTimes.objects.all().iterator(): + if work_time.start_time is not None: + work_time.start_unix_time = int((work_time.start_time).total_seconds()) + if work_time.end_time is not None: + work_time.end_unix_time = int((work_time.end_time).total_seconds()) + work_time.save() + +def reverse_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('work_time', '0002_added_work_time_field'), + ] + + operations = [ + migrations.AddField( + model_name='worktime', + name='end_unix_time', + field=models.PositiveIntegerField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='worktime', + name='start_unix_time', + field=models.PositiveIntegerField(blank=True, editable=False, null=True), + ), + + migrations.RunPython(calculate_unix_start_and_end_times, reverse_func), + + migrations.AlterField( + model_name='worktime', + name='start_unix_time', + field=models.PositiveIntegerField(editable=False), + ), + ] diff --git a/app/time_tracking/work_time/migrations/0004_added_days_count_number.py b/app/time_tracking/work_time/migrations/0004_added_days_count_number.py new file mode 100644 index 0000000..721d4d7 --- /dev/null +++ b/app/time_tracking/work_time/migrations/0004_added_days_count_number.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.9 on 2020-08-07 11:21 + +from django.db import migrations, models + + +def calculate_start_and_end_days(apps, schema_editor): + WorkTimes = apps.get_model('work_time', 'WorkTime') + for work_time in WorkTimes.objects.all().iterator(): + if work_time.start_unix_time is not None: + work_time.days_count = work_time.start_unix_time / 86400 + work_time.save() + +def reverse_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('work_time', '0003_added_unix_start_and_end_time'), + ] + + operations = [ + migrations.AddField( + model_name='worktime', + name='days_count', + field=models.PositiveIntegerField(blank=True, editable=False, null=True), + ), + + migrations.RunPython(calculate_start_and_end_days, reverse_func), + + migrations.AlterField( + model_name='worktime', + name='days_count', + field=models.PositiveIntegerField(editable=False), + ), + ] diff --git a/app/time_tracking/work_time/migrations/0005_refactored_the_model_structure.py b/app/time_tracking/work_time/migrations/0005_refactored_the_model_structure.py new file mode 100644 index 0000000..18cdbd0 --- /dev/null +++ b/app/time_tracking/work_time/migrations/0005_refactored_the_model_structure.py @@ -0,0 +1,75 @@ +# Generated by Django 3.1 on 2020-08-12 16:34 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('work_time', '0004_added_days_count_number'), + ] + + operations = [ + migrations.AddField( + model_name='worktime', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2020, 8, 12, 16, 34, 14, 572268, tzinfo=utc)), + preserve_default=False, + ), + migrations.AddField( + model_name='worktime', + name='start_date', + field=models.DateField(auto_now_add=True, default=datetime.datetime(2020, 8, 12, 16, 34, 27, 220201, tzinfo=utc)), + preserve_default=False, + ), + migrations.AddField( + model_name='worktime', + name='unix_end_time', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='worktime', + name='unix_start_time', + field=models.PositiveIntegerField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name='worktime', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + + + migrations.AlterModelOptions( + name='worktime', + options={'ordering': ['-start_date', '-unix_start_time']}, + ), + + + migrations.RemoveField( + model_name='worktime', + name='days_count', + ), + migrations.RemoveField( + model_name='worktime', + name='end_time', + ), + migrations.RemoveField( + model_name='worktime', + name='end_unix_time', + ), + migrations.RemoveField( + model_name='worktime', + name='start_time', + ), + migrations.RemoveField( + model_name='worktime', + name='start_unix_time', + ), + migrations.RemoveField( + model_name='worktime', + name='work_time', + ), + ] diff --git a/app/time_tracking/work_time/migrations/0006_replaced_unix_time_with_DateTime_fields.py b/app/time_tracking/work_time/migrations/0006_replaced_unix_time_with_DateTime_fields.py new file mode 100644 index 0000000..63dd861 --- /dev/null +++ b/app/time_tracking/work_time/migrations/0006_replaced_unix_time_with_DateTime_fields.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1.1 on 2020-09-06 14:50 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('work_time', '0005_refactored_the_model_structure'), + ] + + operations = [ + migrations.AlterModelOptions( + name='worktime', + options={'ordering': ['-start_datetime']}, + ), + migrations.RemoveField( + model_name='worktime', + name='start_date', + ), + migrations.RemoveField( + model_name='worktime', + name='unix_end_time', + ), + migrations.RemoveField( + model_name='worktime', + name='unix_start_time', + ), + migrations.AddField( + model_name='worktime', + name='end_datetime', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='worktime', + name='start_datetime', + field=models.DateTimeField(default=datetime.datetime(2020, 9, 6, 14, 50, 39, 506003, tzinfo=utc)), + preserve_default=False, + ), + ] diff --git a/app/time_tracking/work_time/migrations/0007_replaced_create_at_and_updated_at_with_TimeStampedModel.py b/app/time_tracking/work_time/migrations/0007_replaced_create_at_and_updated_at_with_TimeStampedModel.py new file mode 100644 index 0000000..cfecf2d --- /dev/null +++ b/app/time_tracking/work_time/migrations/0007_replaced_create_at_and_updated_at_with_TimeStampedModel.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1.1 on 2020-09-13 16:36 + +from django.db import migrations +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('work_time', '0006_replaced_unix_time_with_DateTime_fields'), + ] + + operations = [ + migrations.RenameField( + model_name='worktime', + old_name='created_at', + new_name='created', + ), + migrations.AlterField( + model_name='worktime', + name='created', + field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'), + ), + migrations.RenameField( + model_name='worktime', + old_name='updated_at', + new_name='modified', + ), + migrations.AlterField( + model_name='worktime', + name='modified', + field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'), + ), + ] diff --git a/app/time_tracking/work_time/migrations/__init__.py b/app/time_tracking/work_time/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/time_tracking/work_time/models.py b/app/time_tracking/work_time/models.py new file mode 100644 index 0000000..8bda608 --- /dev/null +++ b/app/time_tracking/work_time/models.py @@ -0,0 +1,19 @@ +from django.contrib.auth.models import User +from django.db import models +from model_utils.models import TimeStampedModel + + +class WorkTime(TimeStampedModel): + + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField(blank=True, null=True) + + owner = models.ForeignKey( + User, + limit_choices_to={'is_staff': False}, + on_delete=models.CASCADE, + related_name='work_times', + ) + + class Meta: + ordering = ['-start_datetime'] diff --git a/app/time_tracking/work_time/serializers.py b/app/time_tracking/work_time/serializers.py new file mode 100644 index 0000000..d20bea1 --- /dev/null +++ b/app/time_tracking/work_time/serializers.py @@ -0,0 +1,41 @@ +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from time_tracking.work_time.models import WorkTime + + +class WorkTimeSerializer(serializers.ModelSerializer): + created = serializers.DateTimeField(read_only=True) + modified = serializers.DateTimeField(read_only=True) + + owner = serializers.ReadOnlyField(source="owner.username") + + class Meta: + model = WorkTime + fields = [ + 'id', 'url', 'created', 'modified', + 'start_datetime', 'end_datetime', 'owner' + ] + + def validate(self, data): + """ + Check that start date is before end date + """ + if data.get('end_datetime') is None and self.instance is not None: + end_date = self.instance.end_datetime + else: + end_date = data.get('end_datetime') + + if end_date is None: + return data + + if data.get('start_datetime') is None: + start_date = self.instance.start_datetime + else: + start_date = data['start_datetime'] + + if start_date > end_date: + raise serializers.ValidationError( + _("Checkout must occur after Checkin datetime") + ) + return data diff --git a/app/time_tracking/work_time/tests.py b/app/time_tracking/work_time/tests.py new file mode 100644 index 0000000..c9c5760 --- /dev/null +++ b/app/time_tracking/work_time/tests.py @@ -0,0 +1,159 @@ +from datetime import timedelta +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from rest_framework import status +from rest_framework.test import APIClient + +from time_tracking.work_time.models import WorkTime +from time_tracking.work_time.serializers import WorkTimeSerializer + +WORK_TIME_URL = reverse('worktime-list') + + +def detail_url(work_time_id): + """Return event detail URL""" + return reverse('worktime-detail', args=[work_time_id]) + + +def sample_work_time(user, **params): + """Create and return a sample WorkTime""" + time_now = timezone.now() + defaults = { + 'start_datetime': time_now + timedelta(days=-1), + 'end_datetime': time_now + timedelta(days=-1) + timedelta(minutes=480, microseconds=555), + } + defaults.update(params) + wt = WorkTime.objects.create(owner=user, **defaults) + wt.save() + return wt + + +class PublicWorkTimesApiTests(TestCase): + """Test the publicly available workTimes API""" + + def setUp(self): + self.client = APIClient() + + def test_login_required(self): + """Test that login is required to access the endpoint""" + res = self.client.get(WORK_TIME_URL) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_create_work_time_without_authentication_should_fail(self): + """Test that login is required to create a new workTime""" + payload = {} + res = self.client.post(WORK_TIME_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PrivateWorkTimesApiTests(TestCase): + """Test the private workTimes API""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'test-user' + 'test@test.com', + '123qwe', + is_staff=False, + ) + self.client.force_authenticate(self.user) + self.other_user = get_user_model().objects.create_user( + 'test-other-user' + 'other@test.com', + '123qwe', + is_staff=False, + ) + + def test_create_checkin_successful(self): + """Test create a new check-in""" + payload = {'start_datetime': timezone.now()} + res = self.client.post(WORK_TIME_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + exists = WorkTime.objects.filter( + start_datetime=payload['start_datetime'], + end_datetime__isnull=True, + owner=self.user, + ).exists() + self.assertTrue(exists, "data should be persisted in the db") + + def test_create_checkin_and_checkout_successful(self): + """Test create a new check-in and check-out""" + payload = {'start_datetime': timezone.now(), 'end_datetime': timezone.now()} + res = self.client.post(WORK_TIME_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + exists = WorkTime.objects.filter( + start_datetime=payload['start_datetime'], + end_datetime=payload['end_datetime'], + owner=self.user, + ).exists() + self.assertTrue(exists, "data should be persisted in the db") + + def test_create_checkin_while_checked_in(self): + """ + Test create a new check-in while the old check-in doesn't have checkout + """ + sample_work_time(self.user, end_datetime=None) + payload = {'start_datetime': timezone.now()} + res = self.client.post(WORK_TIME_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + def test_retrieving_work_times(self): + """Test retrieving the workTime for the user""" + work_time = sample_work_time(self.user) + res = self.client.get(WORK_TIME_URL) + wts = WorkTimeSerializer(data=res.data['results'][0]) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(len(res.data['results']), 1) + + self.assertTrue(wts.is_valid(), f"validate the resulted data isn't correct {wts.errors}") + self.assertEqual(res.data['results'][0]['id'], work_time.id) + self.assertEqual( + wts.validated_data['start_datetime'], + work_time.start_datetime + ) + self.assertEqual( + wts.validated_data['end_datetime'], + work_time.end_datetime + ) + + def test_retrieving_work_times_for_different_owner(self): + """Test retrieving the workTime shouldn't include other users Times""" + sample_work_time(self.other_user) + res = self.client.get(WORK_TIME_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(len(res.data['results']), 0) + + def test_create_should_fail_when_checkout_is_after_checkout_time(self): + """ + Test creating a workTime should fail\ + where the checkin time is after the checkout time + """ + payload = {'start_datetime': timezone.now(), 'end_datetime': timezone.now() - timedelta(hours=5)} + + res = self.client.post(WORK_TIME_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_should_fail_when_checkout_is_after_checkout_time(self): + """ + Test updating a workTime should fail\ + where the checkin time is after the checkout time + """ + work_time = sample_work_time(self.user, end_datetime=None) + url = detail_url(work_time.id) + payload = {'end_datetime': work_time.start_datetime - timedelta(hours=5)} + + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/app/time_tracking/work_time/views.py b/app/time_tracking/work_time/views.py new file mode 100644 index 0000000..8570c46 --- /dev/null +++ b/app/time_tracking/work_time/views.py @@ -0,0 +1,19 @@ +from rest_framework import permissions, viewsets + +from time_tracking.work_time.models import WorkTime +from time_tracking.work_time.serializers import WorkTimeSerializer + + +class WorkTimeViewSet(viewsets.ModelViewSet): + """ + This viewset provides `list`, `detail`, `create`, + `update`, and `delete` for all users. + """ + serializer_class = WorkTimeSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return WorkTime.objects.filter(owner=self.request.user) + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) diff --git a/app/time_tracking/wsgi.py b/app/time_tracking/wsgi.py new file mode 100644 index 0000000..a76c521 --- /dev/null +++ b/app/time_tracking/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for time_tracking project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'time_tracking.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5e5dbf3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.7' + +services: + time_tracking: + build: + context: . + ports: + - "8080:8080" + volumes: + - ./app:/app + command: > + sh -c "python manage.py runserver 0:8080" + environment: + - DB_HOST=postgres + - DB_NAME=time_traking + - DB_USER=postgres + - DB_PASSWORD=secret_password + depends_on: + - postgres + + postgres: + image: postgres:13-alpine + environment: + - POSTGRES_DB=time_traking + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=secret_password + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: \ No newline at end of file